commit 869537cefdad80a4ea36c30cce4b6ec54b9f1a65 Author: Hopping Mad Games Date: Wed Aug 26 09:55:12 2026 -0600 Release standalone timeline editor 1.0.0 diff --git a/.comfyignore b/.comfyignore new file mode 100644 index 0000000..f004b37 --- /dev/null +++ b/.comfyignore @@ -0,0 +1,8 @@ +tests/ +REGISTRY_RELEASE_RESEARCH.md +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.git/ +.github/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6bd077c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.venv/ +dist/ +*.zip diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..646bc74 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project are documented here. + +## 1.0.0 - 2026-08-26 + +- Publish the timeline editor as a standalone ComfyUI node pack. +- Preserve the existing `ETKLTXVTimelineImageEditor` workflow node key. +- Support image and transparent text layers, layer locking, font controls, + outlines, drop shadows, prompts, strengths, and frame positions. +- Return normalized prompt, strength, frame-index, and time lists alongside + edited and unedited image batches. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e62ec04 --- /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..5638979 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# ETK LTXV Timeline Image Editor + +Standalone visual timeline editor for LTXV/LTX 2.x image guides in ComfyUI. + +The editor provides live image and transparent-text composition, layer +locking, font controls, outlines, drop shadows, per-keyframe prompts and guide +strengths, and frame-accurate placement. It emits both composited and original +image batches plus aligned prompt, strength, frame-index, and time lists. + +The workflow node key remains `ETKLTXVTimelineImageEditor`. Existing workflows +therefore load without migration, input renaming, output rewiring, or serialized +timeline conversion. The legacy `EternalKernelLiteGraphNodes` package re-exports +this class for Python callers but no longer registers a second backend copy. + +The pack owns the editor node, timeline schema, image and text-layer rendering, +connected-image handling, and browser editor. It does not load a model, CLIP, +or VAE. + +## Installation + +Install `ETK LTXV Timeline Image Editor` from ComfyUI Manager, then restart +ComfyUI. For a manual installation: + +```bash +cd ComfyUI/custom_nodes +git clone https://git.hoppingmadgames.com/hmg/ComfyUI_ETK_LTXV_Timeline_Editor.git +``` + +No model downloads or additional Python packages are required beyond a current +ComfyUI installation. + +## Use + +Add **ETK LTXV Timeline Image Editor** from `ETK/Image`. Set the target width, +height, LTX-compatible frame length, and frame rate, then open the editor from +the node. Images can also be supplied through the optional `images` input. + +The workflow-facing node key remains `ETKLTXVTimelineImageEditor`; workflows +created with the original node continue to resolve the same node identity. + +See the in-app node help for the complete input and output contract. + +## Validation + +```bash +python3 -m pytest -q tests +node --check web/etk_ltxv_timeline_image_editor.js +find web/ltxv_timeline -name '*.js' -print0 | xargs -0 -n1 node --check +``` + +## License + +GNU General Public License v3.0 or later. See [LICENSE](LICENSE). diff --git a/REGISTRY_RELEASE_RESEARCH.md b/REGISTRY_RELEASE_RESEARCH.md new file mode 100644 index 0000000..d901f2c --- /dev/null +++ b/REGISTRY_RELEASE_RESEARCH.md @@ -0,0 +1,347 @@ +# ComfyUI Registry Release Research for `ComfyUI_ETK_LTXV_Timeline_Editor` + +Research date: 2026-08-26 + +Scope: current official publication process for ComfyUI custom node packs, using official ComfyUI documentation, the official Registry site/repository, and current official tooling source/schema. Local pack inspection was read-only. + +## Primary Official Sources + +- Publishing guide: https://docs.comfy.org/registry/publishing +- Registry overview: https://docs.comfy.org/registry/overview +- `pyproject.toml` spec: https://docs.comfy.org/registry/specifications +- Registry standards: https://docs.comfy.org/registry/standards +- Claim flow: https://docs.comfy.org/registry/claim-my-node +- CI/CD guide: https://docs.comfy.org/registry/cicd +- Node docs: https://docs.comfy.org/custom-nodes/help_page +- Workflow templates: https://docs.comfy.org/custom-nodes/workflow_templates +- Subgraph blueprints: https://docs.comfy.org/custom-nodes/subgraph_blueprints +- Custom node lifecycle: https://docs.comfy.org/custom-nodes/backend/lifecycle +- Node replacement: https://docs.comfy.org/custom-nodes/backend/node-replacement +- Registry site: https://registry.comfy.org/ +- Registry API reference: + - Publisher permissions: https://docs.comfy.org/api-reference/registry/retrieve-permissions-the-user-has-for-a-given-publisher-1 + - Create PAT: https://docs.comfy.org/api-reference/registry/create-a-new-personal-access-token + - Publish version: https://docs.comfy.org/api-reference/registry/publish-a-new-version-of-a-node + - Update changelog/deprecation: https://docs.comfy.org/api-reference/registry/update-changelog-and-deprecation-status-of-a-node-version + - Unpublish version: https://docs.comfy.org/api-reference/registry/unpublish-delete-a-specific-version-of-a-node + - Delete node: https://docs.comfy.org/registry/api-reference/nodes/delete-a-specific-node +- Official tooling source: + - `comfy-cli`: https://github.com/Comfy-Org/comfy-cli + - CLI command source: https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/command/custom_nodes/command.py + - CLI config parser: https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/registry/config_parser.py + - CLI types: https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/registry/types.py + - CLI packaging logic: https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/file_utils.py + - Publish action repo: https://github.com/Comfy-Org/publish-node-action + - Publish action definition: https://raw.githubusercontent.com/Comfy-Org/publish-node-action/main/action.yml + - Comfy CI action: https://github.com/Comfy-Org/comfy-action + - Registry backend repo: https://github.com/Comfy-Org/registry-backend + - Registry OpenAPI: https://raw.githubusercontent.com/Comfy-Org/registry-backend/main/openapi.yml + +## Executive Summary + +The current official flow is: + +1. Create a Comfy Registry account and a publisher. +2. Create a publisher-scoped Registry publishing API key. +3. Add a root `pyproject.toml` with the current Comfy metadata schema. +4. Optionally add `.comfyignore` to control what ships. +5. Publish with `comfy node publish`, or wire the official GitHub Action around that same command. [Source: https://docs.comfy.org/registry/publishing] + +Published versions are immutable once released, and semver is required. Bad releases are handled by publishing a newer version, deprecating the broken one, or deleting it through the registry controls/API. [Source: https://docs.comfy.org/registry/overview ; https://docs.comfy.org/api-reference/registry/update-changelog-and-deprecation-status-of-a-node-version ; https://docs.comfy.org/api-reference/registry/unpublish-delete-a-specific-version-of-a-node] + +For this local pack, the literal Registry submission blockers are the missing required `pyproject.toml` fields, publisher setup/token, repository URL, and semver version. Separate release-readiness gaps include legal/licensing metadata, deterministic packaging controls, CI/publish automation, and the fact that the pack is not currently in its own tracked Git repository state. Local inspection only. + +## 1. Exact Current Publication Workflow and Required Accounts/Permissions + +### Official workflow + +1. Create or sign into a Registry account. The registry site explicitly exposes sign-in and creator publishing. [Source: https://registry.comfy.org/] +2. Create a publisher. The publisher ID is globally unique, appears after `@` on the profile page, and cannot be changed later because it becomes part of the node URL. [Source: https://docs.comfy.org/registry/publishing] +3. Create a Registry publishing API key for that publisher and store it safely. It is the credential used by CLI and GitHub Actions publishing. [Source: https://docs.comfy.org/registry/publishing] +4. Add root metadata in `pyproject.toml`. The publishing docs explicitly point authors to `comfy node init` to generate the scaffold. [Source: https://docs.comfy.org/registry/publishing] +5. Optionally add `.comfyignore` at repo root to exclude tests, docs, design files, and other dev-only artifacts from the published archive. [Source: https://docs.comfy.org/registry/publishing] +6. Publish either: + - manually with `comfy node publish`, or + - through GitHub Actions using `Comfy-Org/publish-node-action`. [Source: https://docs.comfy.org/registry/publishing] + +### Required accounts and permissions + +- A Comfy Registry account is required to create a publisher and publish. [Source: https://docs.comfy.org/registry/publishing] +- A publisher is required for every custom node because `PublisherId` is required in `pyproject.toml`. [Source: https://docs.comfy.org/registry/publishing ; https://docs.comfy.org/registry/specifications] +- A publisher-scoped personal access token is required for non-interactive publishing flows. The current API exposes publisher PAT creation at `POST /publishers/{publisherId}/tokens`. [Source: https://docs.comfy.org/api-reference/registry/create-a-new-personal-access-token] +- Publisher edit authority is represented by `canEdit` on `GET /publishers/{publisherId}/permissions`. [Source: https://docs.comfy.org/api-reference/registry/retrieve-permissions-the-user-has-for-a-given-publisher-1] +- GitHub admin rights are explicitly required for the official "Claim My Node" flow for legacy migrated nodes. The docs state that claim approval is based on verified GitHub admin permissions to the original repository. [Source: https://docs.comfy.org/registry/claim-my-node] + +Inference: for a brand-new node publication, the public docs do not currently state that GitHub admin rights are required by the registry itself, but GitHub repository control is still practically required if you want to configure repository secrets and GitHub Actions publishing. + +## 2. Required and Recommended Repository Files, Metadata, and Validation Commands + +### Required current metadata file and schema + +The required metadata file is `pyproject.toml` at repository root. The official Comfy schema is split across `[project]` and `[tool.comfy]`. [Source: https://docs.comfy.org/registry/specifications] + +Required today: + +- `[project].name`: globally unique node/package identifier with current naming restrictions. [Source: https://docs.comfy.org/registry/specifications] +- `[project].version`: semantic version in `X.Y.Z` form. [Source: https://docs.comfy.org/registry/specifications] +- `[project.urls].Repository`: required repository URL. [Source: https://docs.comfy.org/registry/specifications] +- `[tool.comfy].PublisherId`: required publisher identifier. [Source: https://docs.comfy.org/registry/specifications] + +Recommended today: + +- `[project].description`. [Source: https://docs.comfy.org/registry/specifications] +- `[project].license` using `{ file = "LICENSE" }` or `{ text = "..." }`. The public docs still mark license as optional, but include it in the official example. [Source: https://docs.comfy.org/registry/specifications] +- `[project].requires-python`. [Source: https://docs.comfy.org/registry/specifications] +- `[project.urls].Documentation` and `[project.urls]."Bug Tracker"`. [Source: https://docs.comfy.org/registry/specifications] +- `[project].classifiers` for OS and accelerator support. [Source: https://docs.comfy.org/registry/specifications] +- `[tool.comfy].DisplayName`, `Icon`, `Banner`, `requires-comfyui`, and `includes`. [Source: https://docs.comfy.org/registry/specifications] + +### Frontend compatibility metadata + +If the pack ships frontend JS, the current public spec shows frontend compatibility expressed via a `comfyui-frontend-package...` dependency string in `[project].dependencies`; the official CLI source currently parses that special dependency into `supported_comfyui_frontend_version` metadata. [Source: https://docs.comfy.org/registry/specifications ; https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/registry/config_parser.py] + +### Archive inclusion and exclusion controls + +- `.comfyignore` is the current official exclusion file name. [Source: https://docs.comfy.org/registry/publishing] +- The docs state that `.comfyignore` layers on top of `.gitignore`, and `[tool.comfy].includes` force-includes paths even if they match ignore rules. [Source: https://docs.comfy.org/registry/publishing ; https://docs.comfy.org/registry/specifications] +- The current official CLI source uses Git-tracked files first; if no Git-tracked files are found, it falls back to zipping all files in the directory, then applies `.comfyignore` filtering and `includes`. [Source: https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/file_utils.py] + +### Current official CLI commands + +Official docs explicitly document: + +- `comfy node init` +- `comfy node publish` [Source: https://docs.comfy.org/registry/publishing] + +Current official CLI source also exposes: + +- `comfy node validate` +- `comfy node pack` [Source: https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/command/custom_nodes/command.py] + +Current CLI validation source details: + +- `validate` performs metadata validation and a Ruff-based security scan. +- The security rule set currently includes `S102`, `S307`, and `E702`. [Source: https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/command/custom_nodes/command.py] + +### GitHub Actions publishing + +The public publishing doc still shows an explicit checkout step and a sample using `actions/checkout@v7`, but the current official `publish-node-action` source already performs checkout internally with `actions/checkout@v4` unless `skip_checkout=true`, sets up Python with `actions/setup-python@v5`, installs `comfy-cli`, and runs `comfy node publish --token ...`. [Source: https://docs.comfy.org/registry/publishing ; https://raw.githubusercontent.com/Comfy-Org/publish-node-action/main/action.yml] + +Inference: the action source is the safer source of truth than the older inline docs snippet when they disagree. + +## 3. Versioning, Git Tags/Releases, CI, Ownership, License, Dependency, Frontend, and Security Expectations + +### Versioning + +- Registry node versions must use semantic versioning. [Source: https://docs.comfy.org/registry/overview ; https://docs.comfy.org/registry/specifications] +- Published versions cannot be changed after release. [Source: https://docs.comfy.org/registry/overview] +- The registry publish API is version-oriented and returns a `signedUrl` upload target for the built archive after the version record is created. [Source: https://docs.comfy.org/api-reference/registry/publish-a-new-version-of-a-node] + +Inference: because registry versions are immutable, every public release should map to a single Git commit and a permanent Git tag. A GitHub release is not currently documented as required by Comfy, but it is the cleanest way to preserve provenance for the uploaded artifact. + +### CI expectations + +- Official docs recommend testing custom nodes before publishing using `Comfy-Action` on GitHub Actions, with results visible on `ci.comfy.org`. [Source: https://docs.comfy.org/registry/cicd ; https://github.com/Comfy-Org/comfy-action] + +Inference: for a node pack with both Python and browser code, the minimum practical CI set is syntax/lint/unit checks plus at least one Comfy workflow smoke test. + +### Ownership expectations + +- Publisher IDs are permanent and globally unique. [Source: https://docs.comfy.org/registry/publishing] +- Legacy nodes migrated from ComfyUI-Manager can be claimed only after GitHub admin verification against the original repository. [Source: https://docs.comfy.org/registry/claim-my-node] + +Inference: if this pack is intended to replace or supersede a previously distributed package, repository continuity and clear transfer-of-ownership evidence matter even when the new release path is a brand-new registry submission. + +### License expectations + +- Public docs still label `license` as optional, but the official examples and packaging schema assume a normal Python-package style license declaration. [Source: https://docs.comfy.org/registry/specifications] + +Inference: for public release, a real `LICENSE` file and matching `license` metadata should be treated as mandatory, even if the docs still call it optional, because legal ambiguity is avoidable and unnecessary. + +### Dependency expectations + +- Dependencies belong in `[project].dependencies`. [Source: https://docs.comfy.org/registry/specifications] +- `requires-python` is recommended. [Source: https://docs.comfy.org/registry/specifications] +- `requires-comfyui` is the current official metadata key for backend compatibility. [Source: https://docs.comfy.org/registry/specifications] +- Frontend compatibility is currently expressed through the special frontend dependency handling described above. [Source: https://docs.comfy.org/registry/specifications ; https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/registry/config_parser.py] + +### Frontend expectations + +- `WEB_DIRECTORY` is still the documented mechanism for shipping client-side JS in V1-style custom node modules. [Source: https://docs.comfy.org/custom-nodes/backend/lifecycle] +- Node docs live under `WEB_DIRECTORY/docs/...`. [Source: https://docs.comfy.org/custom-nodes/help_page] +- Example workflow templates live under `example_workflows/`. [Source: https://docs.comfy.org/custom-nodes/workflow_templates] +- Reusable subgraph blueprints live under `subgraphs/`. [Source: https://docs.comfy.org/custom-nodes/subgraph_blueprints] + +### Security expectations + +- `eval` and `exec` are prohibited. [Source: https://docs.comfy.org/registry/standards] +- Runtime package installation through subprocess is prohibited. [Source: https://docs.comfy.org/registry/standards] +- Code obfuscation is prohibited. [Source: https://docs.comfy.org/registry/standards] +- The registry overview says nodes are scanned for malicious behavior and verification flags are used in the UI-manager flow. [Source: https://docs.comfy.org/registry/overview] + +## 4. How Backward-Compatible Workflow Node Keys Affect Publication + +The workflow-critical node identity is the `NODE_CLASS_MAPPINGS` key, not the human-friendly display name. The lifecycle docs state that `NODE_CLASS_MAPPINGS` maps unique node names to node classes, and those names must be unique across the install. [Source: https://docs.comfy.org/custom-nodes/backend/lifecycle] + +For this pack, keeping the workflow node key as `ETKLTXVTimelineImageEditor` is the correct backward-compatibility move for existing saved workflows. Local inspection confirms the pack currently preserves that key in `__init__.py`, and the README explicitly says existing workflows load without migration. Local inspection only. + +Display-name changes are low risk because `NODE_DISPLAY_NAME_MAPPINGS` can change the UI label without changing the underlying workflow key. [Source: https://docs.comfy.org/custom-nodes/backend/lifecycle] + +If the underlying node key ever changes in a future release, the current official migration path is node replacement registration with `io.NodeReplace`, including `old_node_id`, input mapping, output mapping, and `old_widget_ids` when widget order matters. [Source: https://docs.comfy.org/custom-nodes/backend/node-replacement] + +Inference: the registry API exposes `preempted_comfy_node_names` on node objects, which suggests the registry tracks legacy Comfy node-name relationships internally, but I did not find current publisher-facing documentation describing an author-managed metadata field for setting that directly. Supporting API references: https://docs.comfy.org/api-reference/registry/update-a-specific-node and https://docs.comfy.org/api-reference/registry/retrieve-a-node-by-comfyui-node-name + +Practical publication impact: + +- Do not rename `ETKLTXVTimelineImageEditor` for the first registry release if backward compatibility matters. +- If you later need a rename, treat it as a migration release, not a cosmetic cleanup. +- Keep the package ID and the node key conceptually separate: package/repository metadata can evolve more safely than workflow node IDs. + +## 5. Local Pack Gap Analysis + +Inspected path: `/CSA/work/ComfyUI_ETK_LTXV_Timeline_Editor` + +### Literal Registry submission blockers + +1. No `pyproject.toml` exists. + - This blocks the current official publish flow because the registry metadata schema is missing entirely. Local inspection only. + +2. No registry publisher metadata is present. + - There is no `PublisherId`, and no publisher PAT can be inferred from the pack. Local inspection only. + +3. No required repository URL metadata exists. + - `[project.urls].Repository` is required by the current spec, but there is no `pyproject.toml` yet to declare it. [Source: https://docs.comfy.org/registry/specifications] + +4. No release version has been established. + - Current registry publication requires a semver version in `pyproject.toml`. [Source: https://docs.comfy.org/registry/specifications] + +### Release-quality blockers and recommended gates + +These are not all mandatory Registry fields. They should still be resolved before this pack's first public release. + +1. The pack is not currently in a deterministic release-packaging state. + - `git rev-parse --show-toplevel` from the pack resolves to `/CSA`, not the pack directory. + - `git ls-files .` from the pack returns no tracked pack files. + - Under the current official CLI packaging logic, that means publish/pack would fall back to zipping all files in the directory. [Source: https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/file_utils.py ; local inspection] + +2. No `.comfyignore` exists. + - `.comfyignore` is optional, but in the current non-Git-tracked fallback state its absence can sweep development artifacts into the archive. Local inspection only. + +3. No license file or license metadata exists. + - The official spec marks `license` optional. Treating it as a public-release gate is a project recommendation, not a Registry requirement. [Source: https://docs.comfy.org/registry/specifications ; inference] + +4. No explicit compatibility metadata exists. + - `requires-python`, `requires-comfyui`, and frontend compatibility constraints are all optional or recommended in the official spec, but declaring tested ranges reduces installation ambiguity. [Source: https://docs.comfy.org/registry/specifications ; local inspection] + +5. No dependency metadata exists. + - The code imports `numpy`, `torch`, `PIL`, and `folder_paths`; there is also an optional `scipy` fallback path. + - A release review must distinguish ComfyUI-provided runtime modules from third-party packages that actually need declaration. Missing dependency metadata is only a publication blocker if the pack requires an undeclared external package. Local inspection and inference. + +6. No CI workflow exists. + - CI is recommended by the official docs rather than required for publication. There is currently no `.github/workflows` content for validation or publish automation. [Source: https://docs.comfy.org/registry/cicd ; local inspection] + +### Recommended but not strictly hard blockers + +1. Add in-app node documentation under `web/docs/ETKLTXVTimelineImageEditor.md` or locale-specific variants. [Source: https://docs.comfy.org/custom-nodes/help_page] +2. Add `example_workflows/` with at least one working JSON and optional thumbnail. [Source: https://docs.comfy.org/custom-nodes/workflow_templates] +3. Add `subgraphs/` only if the editor has reusable graph blueprints worth exposing. [Source: https://docs.comfy.org/custom-nodes/subgraph_blueprints] +4. Add `Documentation` and `"Bug Tracker"` URLs in `[project.urls]`. [Source: https://docs.comfy.org/registry/specifications] +5. Add OS and accelerator classifiers. [Source: https://docs.comfy.org/registry/specifications] +6. Add icon and banner URLs after assets exist. Note that the docs are currently inconsistent: the spec says icon should be square and at most `400x400`, while the publishing-page scaffold comment still says `MAX. 800x400px`. The spec page is the safer source of truth. [Source: https://docs.comfy.org/registry/specifications ; https://docs.comfy.org/registry/publishing] +7. Add a real changelog policy and release notes process. Inference. + +### Positive findings from local read-only inspection + +- The pack already preserves the workflow node key `ETKLTXVTimelineImageEditor` in `NODE_CLASS_MAPPINGS`. Local inspection only. +- The README already documents that compatibility decision. Local inspection only. +- `WEB_DIRECTORY = "./web"` is present, so the pack is structurally aligned with Comfy's current frontend-loading model. Local inspection only. +- I did not find obvious prohibited `eval`, `exec`, runtime `pip install`, or code obfuscation patterns in the inspected code. Local inspection only. +- Python syntax compilation passed locally with `python3 -m py_compile ...`, and JavaScript syntax checks passed with `node --check ...`. Local inspection only. +- `pytest` could not be executed in this environment because `pytest` is not installed here, so test execution remains unverified. Local inspection only. + +## 6. Minimal Ordered Release Checklist + +1. Put the pack into its own Git-tracked public release repository, or otherwise ensure that the exact release contents are fully Git-tracked from the pack root. +2. Decide the permanent package ID (`[project].name`) and permanent publisher ID strategy. +3. Create the Registry account, create the publisher, and create a publisher PAT. [Source: https://docs.comfy.org/registry/publishing] +4. If this should inherit ownership from a migrated legacy listing, verify whether the official claim flow is needed and perform it with the repository admin account. [Source: https://docs.comfy.org/registry/claim-my-node] +5. Add `pyproject.toml` with: + - required `name`, `version`, `Repository`, and `PublisherId` + - explicit `license` + - explicit `requires-python` + - explicit `requires-comfyui` + - explicit dependency declarations + - explicit frontend compatibility declaration if you intend to support current frontend ranges. [Source: https://docs.comfy.org/registry/specifications] +6. Add `LICENSE`. +7. Add `.comfyignore` so the published archive excludes tests, caches, and dev-only assets. [Source: https://docs.comfy.org/registry/publishing] +8. Add CI: + - Python and JS syntax/lint checks + - unit tests + - at least one Comfy workflow smoke test via `Comfy-Action`. [Source: https://docs.comfy.org/registry/cicd] +9. Add user-facing release support assets: + - node docs + - example workflows + - bug tracker/documentation URLs + - optional icon/banner +10. Run `comfy node validate` and `comfy node pack`; inspect the archive contents before the first publish. [Source: https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/command/custom_nodes/command.py] +11. Tag the release commit before publish. Inference. +12. Publish with `comfy node publish` or the official publish action. [Source: https://docs.comfy.org/registry/publishing ; https://raw.githubusercontent.com/Comfy-Org/publish-node-action/main/action.yml] +13. Verify: + - registry page renders correctly + - install works in ComfyUI-Manager + - existing workflows using `ETKLTXVTimelineImageEditor` still load + - frontend JS loads correctly on a supported ComfyUI/frontend version. + +## 7. Minimal Rollback and Update Process + +### Update process + +1. Make the code change. +2. Keep the node key stable unless the release is an intentional migration release. +3. Bump semver in `pyproject.toml`. [Source: https://docs.comfy.org/registry/specifications] +4. Re-run local validation and CI. +5. Publish the new version. +6. Verify install and workflow compatibility in ComfyUI-Manager. + +### Rollback process + +Because published versions are immutable, rollback is not "replace the broken artifact." The current official options are: [Source: https://docs.comfy.org/registry/overview ; https://docs.comfy.org/api-reference/registry/update-changelog-and-deprecation-status-of-a-node-version ; https://docs.comfy.org/api-reference/registry/unpublish-delete-a-specific-version-of-a-node] + +1. Publish a fixed higher version as the preferred recovery path. +2. Deprecate the bad version through the Registry UI or `PUT /publishers/{publisherId}/nodes/{nodeId}/versions/{versionId}` with `deprecated=true` and a changelog/deprecation message. +3. If necessary, unpublish the bad version with `DELETE /publishers/{publisherId}/nodes/{nodeId}/versions/{versionId}`. +4. If the whole node listing must be removed, the API also exposes node deletion at the publisher/node level. [Source: https://docs.comfy.org/registry/api-reference/nodes/delete-a-specific-node] + +Inference: for trust and reproducibility, deprecate first and keep a visible explanation whenever possible; use deletion only when the release is invalid or should not remain publicly installable. + +## 8. Specific Current Gaps To Resolve For This Pack Before First Release + +These are the concrete items I would resolve before attempting a first registry publish: + +- Create a dedicated tracked release repository state for the pack. +- Add `pyproject.toml`. +- Choose the permanent package ID. +- Choose the permanent publisher ID and create the PAT. +- Add a license file and metadata. +- Declare Python dependencies and compatibility ranges. +- Declare ComfyUI compatibility via `requires-comfyui`. +- Declare frontend compatibility because this pack ships JS. +- Add `.comfyignore`. +- Add CI. +- Add at least one example workflow. +- Add node docs. +- Decide whether icon/banner assets are ready. +- Confirm whether any legacy listing or claim path needs to be reconciled before publication. + +## 9. Notes on Current Docs vs Tooling Mismatches + +1. The publishing page's sample comment still says icon max `800x400`, while the spec page says icons should be square and max `400x400`. I would follow the spec page. [Source: https://docs.comfy.org/registry/publishing ; https://docs.comfy.org/registry/specifications] +2. The publishing page's GitHub Actions snippet shows explicit checkout with `actions/checkout@v7`, while the current official action source already performs checkout with `actions/checkout@v4` unless `skip_checkout=true`. I would follow the action source. [Source: https://docs.comfy.org/registry/publishing ; https://raw.githubusercontent.com/Comfy-Org/publish-node-action/main/action.yml] +3. The public publishing page documents `init` and `publish`, but the current official CLI source also provides `validate` and `pack`. I would use both before first release. [Source: https://docs.comfy.org/registry/publishing ; https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/command/custom_nodes/command.py] +4. The current CLI source supports additional version-resolution behavior not clearly documented on the public spec page. For a first release, static `project.version = "X.Y.Z"` is the lowest-risk path. [Source: https://raw.githubusercontent.com/Comfy-Org/comfy-cli/main/comfy_cli/registry/config_parser.py ; inference] + +## 10. Main-Agent Verification + +After restoring this report from the research agent's durable session record, the main agent independently re-opened the current official publishing page, `pyproject.toml` specification, CI/CD page, and `publish-node-action` definition on 2026-08-26. The required metadata, publisher/token flow, `.comfyignore` behavior, CLI/action publication paths, and documented action/docs mismatch described above were confirmed. + +The live extracted pack on `csa-host` also passed its contract test (`3 passed in 1.22s`), Python compilation, and JavaScript `node --check` validation. This supersedes the research agent's narrower note that `pytest` was unavailable inside its own isolated research environment. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a67f609 --- /dev/null +++ b/__init__.py @@ -0,0 +1,21 @@ +"""Standalone ETK LTXV Timeline Image Editor node pack.""" + +from .timeline import ETKLTXVTimelineImageEditor + + +NODE_CLASS_MAPPINGS = { + "ETKLTXVTimelineImageEditor": ETKLTXVTimelineImageEditor, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "ETKLTXVTimelineImageEditor": "ETK LTXV Timeline Image Editor", +} + +WEB_DIRECTORY = "./web" + +__all__ = [ + "ETKLTXVTimelineImageEditor", + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", + "WEB_DIRECTORY", +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cfea645 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "etk-ltxv-timeline-editor" +version = "1.0.0" +description = "A visual timeline editor for LTXV image guides, prompts, strengths, and frame positions in ComfyUI." +license = { file = "LICENSE" } +requires-python = ">=3.10" +dependencies = [ + "comfyui-frontend-package>=1.45.0", +] +classifiers = [ + "Operating System :: OS Independent", +] + +[project.urls] +Repository = "https://git.hoppingmadgames.com/hmg/ComfyUI_ETK_LTXV_Timeline_Editor" +Documentation = "https://git.hoppingmadgames.com/hmg/ComfyUI_ETK_LTXV_Timeline_Editor/src/branch/main/README.md" +"Bug Tracker" = "https://git.hoppingmadgames.com/hmg/ComfyUI_ETK_LTXV_Timeline_Editor/issues" + +[tool.comfy] +PublisherId = "hmg" +DisplayName = "ETK LTXV Timeline Image Editor" +requires-comfyui = ">=0.33.0" diff --git a/tests/test_editor_contract.py b/tests/test_editor_contract.py new file mode 100644 index 0000000..3eb8f82 --- /dev/null +++ b/tests/test_editor_contract.py @@ -0,0 +1,86 @@ +import json + +from custom_nodes.ComfyUI_ETK_LTXV_Timeline_Editor import ( + ETKLTXVTimelineImageEditor, + NODE_CLASS_MAPPINGS, + NODE_DISPLAY_NAME_MAPPINGS, +) + + +def test_workflow_identity_and_ports_are_stable(): + assert NODE_CLASS_MAPPINGS == { + "ETKLTXVTimelineImageEditor": ETKLTXVTimelineImageEditor, + } + assert NODE_DISPLAY_NAME_MAPPINGS == { + "ETKLTXVTimelineImageEditor": "ETK LTXV Timeline Image Editor", + } + assert ETKLTXVTimelineImageEditor.CATEGORY == "ETK/Image" + assert ETKLTXVTimelineImageEditor.FUNCTION == "generate" + assert ETKLTXVTimelineImageEditor.RETURN_TYPES == ( + "IMAGE", + "IMAGE", + "INT", + "INT", + "INT", + "LIST", + "LIST", + "LIST", + "LIST", + ) + assert ETKLTXVTimelineImageEditor.RETURN_NAMES == ( + "edited_images", + "unedited_images", + "width", + "height", + "length", + "strengths", + "frame_indexes", + "position_seconds", + "prompts", + ) + + +def test_empty_timeline_preserves_result_shape_and_settings(): + output = ETKLTXVTimelineImageEditor().generate( + width=96, + height=64, + length=17, + timeline_json="[]", + fps=24.0, + ) + + assert output["result"][0].shape == (1, 64, 96, 3) + assert output["result"][1].shape == (1, 64, 96, 4) + assert output["result"][2:5] == (96, 64, 17) + assert output["result"][5:] == ([], [], [], []) + settings = json.loads(output["ui"]["etk_ltxv_timeline_settings"][0]) + assert settings == { + "width": 96, + "height": 64, + "length": 17, + "fps": 24.0, + "ui_instance_id": "", + } + + +def test_list_inputs_keep_prompt_strength_and_time_alignment(): + output = ETKLTXVTimelineImageEditor().generate( + width=96, + height=64, + length=17, + timeline_json="[]", + fps=8.0, + prompts=[["first", "second"]], + strengths=[[0.25, 0.75]], + times=[[0.0, 1.0]], + ) + + result = output["result"] + assert result[5] == [] + assert result[6] == [] + assert result[7] == [] + assert result[8] == [] + keyframes = json.loads(output["ui"]["etk_ltxv_timeline_keyframes"][0])["keyframes"] + assert [item["positivePrompt"] for item in keyframes] == ["first", "second"] + assert [item["latentStrength"] for item in keyframes] == [0.25, 0.75] + assert [item["time"] for item in keyframes] == [0.0, 1.0] diff --git a/timeline/__init__.py b/timeline/__init__.py new file mode 100644 index 0000000..864a8d6 --- /dev/null +++ b/timeline/__init__.py @@ -0,0 +1,6 @@ +"""Timeline editor backend.""" + +from .editor import ETKLTXVTimelineImageEditor + + +__all__ = ["ETKLTXVTimelineImageEditor"] diff --git a/timeline/core.py b/timeline/core.py new file mode 100644 index 0000000..86f69b6 --- /dev/null +++ b/timeline/core.py @@ -0,0 +1,229 @@ +"""Pure timeline schedule helpers for ETK LTXV timeline image editor nodes.""" + +import ast +import json + + +ETK_LTXV_TIMELINE_SCHEMA_VERSION = 2 + +def _etk_ltxv_timeline_payload_keyframes(payload): + schema_version = None + if isinstance(payload, dict): + raw_version = payload.get("schemaVersion", payload.get("schema_version", None)) + if raw_version is not None: + try: + schema_version = int(raw_version) + except (TypeError, ValueError) as exc: + raise ValueError("timeline_json schemaVersion must be an integer") from exc + if schema_version < 1 or schema_version > ETK_LTXV_TIMELINE_SCHEMA_VERSION: + raise ValueError(f"unsupported timeline_json schemaVersion {schema_version}") + payload = payload.get("keyframes", []) + if not isinstance(payload, list): + raise ValueError("timeline_json must be a list or an object with a keyframes list") + return payload, schema_version + +def _parse_etk_prompt_schedule(prompt_schedule): + if prompt_schedule is None: + return [] + if isinstance(prompt_schedule, str): + text = prompt_schedule.strip() + if not text: + return [] + try: + payload = json.loads(text) + except json.JSONDecodeError: + try: + payload = ast.literal_eval(text) + except (SyntaxError, ValueError) as exc: + raise ValueError( + "prompt_schedule must be a JSON/Python list of " + "(image_index, seconds, positive_prompt, latent_strength, prompt_strength) " + "or legacy (seconds, positive_prompt, latent_strength, prompt_strength) tuples" + ) from exc + else: + payload = prompt_schedule + + if isinstance(payload, dict): + payload = payload.get("keyframes", payload.get("schedule", [])) + if not isinstance(payload, (list, tuple)): + raise ValueError("prompt_schedule must be a list") + + schedule = [] + for index, item in enumerate(payload): + image_index = None + if isinstance(item, dict): + image_index = item.get("image_index", item.get("imageIndex", item.get("image", None))) + seconds = item.get("seconds", item.get("time", item.get("t", item.get("position", item.get("keyframe_position_seconds"))))) + prompt = item.get("positive_prompt", item.get("positivePrompt", item.get("prompt", ""))) + latent_strength = item.get("latent_strength", item.get("latentStrength", item.get("strength", 1.0))) + prompt_strength = item.get("prompt_strength", item.get("promptStrength", 1.0)) + elif isinstance(item, (list, tuple)) and len(item) >= 2: + has_image_index = False + if len(item) >= 3: + try: + float(item[1]) + has_image_index = True + except (TypeError, ValueError): + has_image_index = False + if has_image_index: + image_index = item[0] + seconds = item[1] + prompt = item[2] + latent_strength = item[3] if len(item) >= 4 else 1.0 + prompt_strength = item[4] if len(item) >= 5 else 1.0 + else: + seconds = item[0] + prompt = item[1] + latent_strength = item[2] if len(item) >= 3 else 1.0 + prompt_strength = item[3] if len(item) >= 4 else 1.0 + else: + raise ValueError(f"prompt_schedule item {index} must be a tuple/list or dict") + + try: + seconds = max(0.0, float(seconds)) + if image_index is not None: + image_index = int(image_index) + if image_index < 0: + raise ValueError + latent_strength = max(0.0, min(1.0, float(latent_strength))) + prompt_strength = max(0.0, min(4.0, float(prompt_strength))) + except (TypeError, ValueError) as exc: + raise ValueError(f"prompt_schedule item {index} has invalid image index, seconds, or strength") from exc + prompt = str(prompt or "").strip() + if not prompt: + continue + schedule.append({ + "image_index": image_index, + "seconds": seconds, + "positive_prompt": prompt, + "latent_strength": latent_strength, + "prompt_strength": prompt_strength, + }) + + return schedule + +def _apply_etk_prompt_schedule_override(keyframes, prompt_schedule, fps, latent_frames, reset_existing=False): + schedule = _parse_etk_prompt_schedule(prompt_schedule) + if not schedule: + return keyframes, False + + try: + fps = max(1e-6, float(fps)) + except (TypeError, ValueError) as exc: + raise ValueError("prompt_schedule_fps must be a number") from exc + latent_frames = max(1, int(latent_frames)) + + merged = [] if reset_existing else [dict(keyframe) for keyframe in keyframes] + by_slot = {} + for keyframe in merged: + slot = max(0, min(latent_frames - 1, int(keyframe["slot"]))) + keyframe["slot"] = slot + keyframe["frame"] = slot * 8 + by_slot[slot] = keyframe + + for entry in schedule: + frame = int(round(entry["seconds"] * fps)) + slot = max(0, min(latent_frames - 1, int((frame / 8.0) + 0.5))) + keyframe = by_slot.get(slot) + if keyframe is None: + keyframe = { + "frame": slot * 8, + "slot": slot, + "fit_mode": "crop", + "pan_x": 0.0, + "pan_y": 0.0, + "zoom": 1.0, + "image": None, + "layers": [], + "input_image": False, + "positive_prompt": "", + "negative_prompt": "", + "prompt_strength": 1.0, + "latent_strength": None, + } + merged.append(keyframe) + by_slot[slot] = keyframe + keyframe["positive_prompt"] = entry["positive_prompt"] + keyframe["latent_strength"] = entry["latent_strength"] + keyframe["prompt_strength"] = entry["prompt_strength"] + keyframe.pop("time", None) + if entry.get("image_index") is None: + keyframe.pop("image_index", None) + else: + keyframe["image_index"] = entry["image_index"] + + merged = [ + keyframe + for keyframe in merged + if keyframe.get("image") + or keyframe.get("layers") + or keyframe.get("_input_image") is not None + or keyframe.get("input_image") + or str(keyframe.get("positive_prompt", "") or "").strip() + or str(keyframe.get("negative_prompt", "") or "").strip() + ] + return sorted(merged, key=lambda item: item["slot"]), True + +def _etk_timeline_keyframes_for_ui(keyframes, fps=25.0): + try: + fps = max(0.001, float(fps)) + except (TypeError, ValueError): + fps = 25.0 + ui_keyframes = [] + for keyframe in sorted(keyframes, key=lambda item: item["slot"]): + ui_keyframe = { + "time": float(keyframe.get("time", int(keyframe["frame"]) / fps)), + "fitMode": keyframe.get("fit_mode", "crop"), + "panX": float(keyframe.get("pan_x", 0.0)), + "panY": float(keyframe.get("pan_y", 0.0)), + "zoom": float(keyframe.get("zoom", 1.0)), + "brightness": float(keyframe.get("brightness", 1.0)), + "contrast": float(keyframe.get("contrast", 1.0)), + "positivePrompt": str(keyframe.get("positive_prompt", "") or ""), + "negativePrompt": str(keyframe.get("negative_prompt", "") or ""), + "promptStrength": float(keyframe.get("prompt_strength", 1.0)), + } + if keyframe.get("latent_strength") is not None: + ui_keyframe["latentStrength"] = float(keyframe.get("latent_strength", 1.0)) + if keyframe.get("image"): + ui_keyframe["image"] = keyframe["image"] + if keyframe.get("layers"): + ui_keyframe["layers"] = [] + for layer in keyframe.get("layers", []): + ui_layer = { + "id": str(layer.get("id", "") or ""), + "fitMode": layer.get("fit_mode", "crop"), + "panX": float(layer.get("pan_x", 0.0)), + "panY": float(layer.get("pan_y", 0.0)), + "zoom": float(layer.get("zoom", 1.0)), + "brightness": float(layer.get("brightness", 1.0)), + "contrast": float(layer.get("contrast", 1.0)), + } + if layer.get("type") == "text": + ui_layer.update({ + "type": "text", + "text": str(layer.get("text", "Text") or ""), + "fontFamily": str(layer.get("font_family", layer.get("fontFamily", "sans-serif")) or "sans-serif"), + "fontSize": float(layer.get("font_size", layer.get("fontSize", 72.0))), + "bold": bool(layer.get("bold", False)), + "italic": bool(layer.get("italic", False)), + "align": str(layer.get("align", layer.get("textAlign", "center")) or "center"), + "color": str(layer.get("color", "#ffffff") or "#ffffff"), + "outlineColor": str(layer.get("outline_color", layer.get("outlineColor", "#000000")) or "#000000"), + "outlineWidth": float(layer.get("outline_width", layer.get("outlineWidth", 0.0))), + "shadowColor": str(layer.get("shadow_color", layer.get("shadowColor", "#000000")) or "#000000"), + "shadowBlur": float(layer.get("shadow_blur", layer.get("shadowBlur", 0.0))), + "shadowOffsetX": float(layer.get("shadow_offset_x", layer.get("shadowOffsetX", 0.0))), + "shadowOffsetY": float(layer.get("shadow_offset_y", layer.get("shadowOffsetY", 0.0))), + }) + elif layer.get("image"): + ui_layer["image"] = layer["image"] + else: + ui_layer["empty"] = True + ui_keyframe["layers"].append(ui_layer) + if ui_keyframe["layers"]: + ui_keyframe["selectedLayerId"] = ui_keyframe["layers"][0]["id"] + if keyframe.get("_input_image") is not None or keyframe.get("input_image"): + ui_keyframe["inputImage"] = True + ui_keyframes.append(ui_keyframe) + return ui_keyframes diff --git a/timeline/editor.py b/timeline/editor.py new file mode 100644 index 0000000..4a310e0 --- /dev/null +++ b/timeline/editor.py @@ -0,0 +1,305 @@ +"""Standalone LTXV timeline image-editor ComfyUI node.""" + +import math + +from .images import ( + _etk_scalar_input, + _etk_send_timeline_settings_to_ui, + _etk_timeline_editor_compat_inputs, + _etk_timeline_result_with_ui, + _etk_timeline_with_connected_images, + _etk_unwrap_single_input, +) +from .rendering import ( + _etk_timeline_image_batch, + _etk_timeline_unedited_source_batch, + _fit_etk_timeline_keyframe_image, + _load_etk_timeline_keyframe_unedited_sources, +) +from .schema import _parse_etk_ltxv_timeline + + +def _etk_keyframe_latent_strength(keyframe, global_strength): + latent_strength = keyframe.get("latent_strength", None) + if latent_strength is None: + latent_strength = global_strength + try: + latent_strength = float(latent_strength) + except (TypeError, ValueError): + latent_strength = float(global_strength) + return max(0.0, min(1.0, latent_strength)) + + +def _etk_timeline_list_input(name, value): + """Unwrap one Comfy list-transport layer without collapsing singleton lists.""" + if value is None: + return None + if not isinstance(value, (list, tuple)): + raise ValueError(f"{name} must be a LIST") + values = list(value) + if len(values) == 1 and isinstance(values[0], (list, tuple)): + values = list(values[0]) + return values + + +def _etk_timeline_time_fields(value, index, fps, latent_frames): + try: + time_seconds = float(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"times[{index}] must be a number of seconds") from exc + if not math.isfinite(time_seconds) or time_seconds < 0.0: + raise ValueError(f"times[{index}] must be a finite, non-negative number of seconds") + + frame = int(round(time_seconds * fps)) + max_frame = max(0, (int(latent_frames) - 1) * 8) + if frame > max_frame: + raise ValueError( + f"times[{index}]={time_seconds} seconds maps to frame {frame}, " + f"outside video frame range 0..{max_frame}" + ) + return time_seconds, frame, int((frame / 8.0) + 0.5) + + +def _etk_empty_timeline_keyframe(slot, fps): + frame = int(slot) * 8 + return { + "frame": frame, + "slot": int(slot), + "time": frame / fps, + "fit_mode": "crop", + "pan_x": 0.0, + "pan_y": 0.0, + "zoom": 1.0, + "brightness": 1.0, + "contrast": 1.0, + "image": None, + "layers": [], + "selected_layer_id": "", + "input_image": False, + "positive_prompt": "", + "negative_prompt": "", + "prompt_strength": 1.0, + "latent_strength": None, + } + + +def _etk_apply_timeline_list_inputs(keyframes, prompts, strengths, times, fps, latent_frames): + supplied = { + "prompts": _etk_timeline_list_input("prompts", prompts), + "strengths": _etk_timeline_list_input("strengths", strengths), + "times": _etk_timeline_list_input("times", times), + } + provided = {name: values for name, values in supplied.items() if values is not None} + if not provided: + return keyframes, None + + lengths = {name: len(values) for name, values in provided.items()} + if len(set(lengths.values())) != 1: + details = ", ".join(f"{name}={count}" for name, count in lengths.items()) + raise ValueError(f"timeline list inputs must have equal lengths; got {details}") + item_count = next(iter(lengths.values())) + + merged = [dict(keyframe) for keyframe in keyframes] + if len(merged) > item_count: + raise ValueError( + f"timeline list inputs contain {item_count} items, but timeline_json contains " + f"{len(merged)} keyframes" + ) + + occupied_slots = {int(keyframe["slot"]) for keyframe in merged} + cursor = max(occupied_slots, default=-1) + 1 + while len(merged) < item_count: + index = len(merged) + if supplied["times"] is not None: + time_seconds, frame, slot = _etk_timeline_time_fields( + supplied["times"][index], index, fps, latent_frames + ) + keyframe = _etk_empty_timeline_keyframe(slot, fps) + keyframe.update({"time": time_seconds, "frame": frame, "slot": slot}) + else: + while cursor in occupied_slots: + cursor += 1 + if cursor >= latent_frames: + raise ValueError( + f"timeline list inputs require {item_count} keyframes, but no free latent " + f"slot remains within 0..{latent_frames - 1}" + ) + keyframe = _etk_empty_timeline_keyframe(cursor, fps) + cursor += 1 + occupied_slots.add(int(keyframe["slot"])) + merged.append(keyframe) + + for index, keyframe in enumerate(merged): + if supplied["prompts"] is not None: + prompt = supplied["prompts"][index] + if isinstance(prompt, (list, tuple, dict)): + raise ValueError(f"prompts[{index}] must be a string value") + keyframe["positive_prompt"] = str(prompt if prompt is not None else "") + if supplied["strengths"] is not None: + try: + strength = float(supplied["strengths"][index]) + except (TypeError, ValueError) as exc: + raise ValueError(f"strengths[{index}] must be a number") from exc + if not math.isfinite(strength): + raise ValueError(f"strengths[{index}] must be finite") + keyframe["latent_strength"] = max(0.0, min(1.0, strength)) + if supplied["times"] is not None: + time_seconds, frame, slot = _etk_timeline_time_fields( + supplied["times"][index], index, fps, latent_frames + ) + keyframe.update({"time": time_seconds, "frame": frame, "slot": slot}) + + return sorted(merged, key=lambda item: (item["slot"], item["frame"])), item_count + + +class ETKLTXVTimelineImageEditor: + DISPLAY_NAME = "ETK LTXV Timeline Image Editor" + DESCRIPTION = ( + "Standalone LTXV/LTX 2.x image timeline editor. It uses the same " + "timeline image-editing UI as the all-in-one PromptRelay node, then " + "emits composited preview images, matching unedited source images, " + "per-keyframe guide strengths, frame indexes, and normalized positive " + "prompts without loading a model, CLIP, or VAE. Optional prompt, " + "strength, and time lists replace keyframe metadata by index." + ) + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "width": ("INT", {"default": 768, "min": 64, "max": 8192, "step": 32}), + "height": ("INT", {"default": 512, "min": 64, "max": 8192, "step": 32}), + "length": ("INT", {"default": 97, "min": 1, "max": 8192, "step": 8}), + "timeline_json": ("STRING", {"default": "[]", "multiline": True}), + "fps": ("FLOAT", {"default": 25.0, "min": 0.001, "max": 240.0, "step": 0.01}), + }, + "optional": { + "images": ("IMAGE",), + "ui_instance_id": ("STRING", {"default": "", "multiline": False}), + "prompts": ("LIST", { + "tooltip": "Positive prompts aligned one-to-one with timeline keyframes/images.", + }), + "strengths": ("LIST", { + "tooltip": "Image guide strengths aligned one-to-one with timeline keyframes/images.", + }), + "times": ("LIST", { + "tooltip": "Keyframe times in seconds, aligned one-to-one with timeline keyframes/images.", + }), + }, + "hidden": { + "unique_id": "UNIQUE_ID", + }, + } + + RETURN_TYPES = ("IMAGE", "IMAGE", "INT", "INT", "INT", "LIST", "LIST", "LIST", "LIST") + RETURN_NAMES = ( + "edited_images", + "unedited_images", + "width", + "height", + "length", + "strengths", + "frame_indexes", + "position_seconds", + "prompts", + ) + INPUT_IS_LIST = True + OUTPUT_IS_LIST = (False, False, False, False, False, False, False, False, False) + FUNCTION = "generate" + CATEGORY = "ETK/Image" + + def generate( + self, + width, + height, + length, + timeline_json="[]", + fps=25.0, + strength=1.0, + images=None, + ui_instance_id="", + unique_id=None, + prompts=None, + strengths=None, + times=None, + ): + width = _etk_scalar_input(width) + height = _etk_scalar_input(height) + length = _etk_scalar_input(length) + timeline_json = _etk_scalar_input(timeline_json) + fps = _etk_scalar_input(fps) + strength = _etk_scalar_input(strength) + images = _etk_unwrap_single_input(images) + ui_instance_id = _etk_scalar_input(ui_instance_id) + unique_id = _etk_scalar_input(unique_id) + + width = max(64, int(round(int(width) / 32)) * 32) + height = max(64, int(round(int(height) / 32)) * 32) + length = max(1, int(length)) + if (length - 1) % 8 != 0: + raise ValueError("LTXV video length must be 1 plus a multiple of 8") + try: + fps = max(0.001, float(fps)) + except (TypeError, ValueError) as exc: + raise ValueError("timeline fps must be a number") from exc + timeline_json, strength = _etk_timeline_editor_compat_inputs(timeline_json, strength) + + latent_frames = ((length - 1) // 8) + 1 + keyframes, list_item_count = _etk_apply_timeline_list_inputs( + _parse_etk_ltxv_timeline(timeline_json, include_empty=True, fps=fps), + prompts, + strengths, + times, + fps, + latent_frames, + ) + keyframes = _etk_timeline_with_connected_images( + keyframes, + images, + latent_frames, + ) + if list_item_count is not None and len(keyframes) != list_item_count: + raise ValueError( + f"timeline list inputs contain {list_item_count} items, but timeline/images " + f"contain {len(keyframes)} keyframes" + ) + + fitted_images = [] + unedited_images = [] + output_strengths = [] + frame_indexes = [] + position_seconds = [] + output_prompts = [] + for keyframe in keyframes: + fitted = _fit_etk_timeline_keyframe_image(keyframe, width, height) + if fitted is not None: + fitted_images.append(fitted.copy()) + for unedited in _load_etk_timeline_keyframe_unedited_sources(keyframe, width, height): + unedited_images.append(unedited.copy()) + output_strengths.append(_etk_keyframe_latent_strength(keyframe, strength)) + frame_index = int(keyframe["frame"]) + frame_indexes.append(frame_index) + position_seconds.append(frame_index / fps) + output_prompts.append(str(keyframe.get("positive_prompt", "") or "")) + + settings = {"width": width, "height": height, "length": length, "fps": fps} + _etk_send_timeline_settings_to_ui(unique_id, settings, ui_instance_id=ui_instance_id) + + result = ( + _etk_timeline_image_batch(fitted_images, width, height), + _etk_timeline_unedited_source_batch(unedited_images, width, height), + width, + height, + length, + output_strengths, + frame_indexes, + position_seconds, + output_prompts, + ) + return _etk_timeline_result_with_ui( + result, + keyframes, + force_ui=images is not None or list_item_count is not None, + settings=settings, + ui_instance_id=ui_instance_id, + ) diff --git a/timeline/images.py b/timeline/images.py new file mode 100644 index 0000000..ef7573f --- /dev/null +++ b/timeline/images.py @@ -0,0 +1,406 @@ +"""Timeline image resolution, conversion, connected-input, and UI payload helpers.""" + +import hashlib +import json +import os + +import folder_paths +import numpy as np +import torch +from PIL import Image, ImageOps + +from .core import ( + ETK_LTXV_TIMELINE_SCHEMA_VERSION, + _etk_timeline_keyframes_for_ui, +) + + +def _resolve_etk_timeline_image_path(image_info): + if isinstance(image_info, str): + filename = image_info + subfolder = "" + folder_type = "input" + elif isinstance(image_info, dict): + filename = image_info.get("filename") or image_info.get("name") + subfolder = image_info.get("subfolder") or "" + folder_type = image_info.get("type") or "input" + else: + raise ValueError("timeline image entry must be a filename string or file info object") + + if not filename: + raise ValueError("timeline image entry is missing filename") + + base_dir = folder_paths.get_directory_by_type(folder_type) + if not base_dir: + raise ValueError(f"unknown ComfyUI folder type for timeline image: {folder_type}") + + base_path = os.path.abspath(base_dir) + candidate = os.path.abspath(os.path.join(base_path, subfolder, filename)) + if os.path.commonpath([base_path, candidate]) != base_path: + raise ValueError(f"timeline image path escapes ComfyUI {folder_type} directory") + if not os.path.isfile(candidate): + raise FileNotFoundError(candidate) + return candidate + + +def _fit_etk_timeline_image(image, width, height, fit_mode, pan_x=0.0, pan_y=0.0, zoom=1.0, transparent_pad=False): + image = ImageOps.exif_transpose(image) + has_alpha = "A" in image.getbands() + image = image.convert("RGBA") + pan_x = float(pan_x) + pan_y = float(pan_y) + zoom = max(0.01, min(20.0, float(zoom))) + if fit_mode == "pad": + scale = min(width / image.width, height / image.height) * zoom + else: + scale = max(width / image.width, height / image.height) * zoom + + resized = image.resize( + (max(1, round(image.width * scale)), max(1, round(image.height * scale))), + Image.Resampling.LANCZOS, + ) + pan_range_x = max(max(0, resized.width - width), width) + pan_range_y = max(max(0, resized.height - height), height) + paste_x = round((width - resized.width) / 2 + pan_x * pan_range_x / 2) + paste_y = round((height - resized.height) / 2 + pan_y * pan_range_y / 2) + source_left = max(0, -paste_x) + source_top = max(0, -paste_y) + source_right = min(resized.width, width - paste_x) + source_bottom = min(resized.height, height - paste_y) + + canvas_alpha = 0 if (transparent_pad or has_alpha) else 255 + canvas = Image.new("RGBA", (width, height), (0, 0, 0, canvas_alpha)) + if source_right > source_left and source_bottom > source_top: + crop = resized.crop((source_left, source_top, source_right, source_bottom)) + # Preserve RGB under transparent pixels so VAE encode can explicitly strip alpha later. + canvas.paste(crop, (max(0, paste_x), max(0, paste_y))) + return canvas + + +def _pil_to_comfy_image_tensor(image): + array = np.asarray(image, dtype=np.float32) / 255.0 + return torch.from_numpy(array) + + +def _etk_rgb_with_alpha_bleed(image, bleed_px): + rgba = image.convert("RGBA") + array = np.asarray(rgba, dtype=np.float32) / 255.0 + rgb = array[:, :, :3].copy() + alpha = array[:, :, 3] + opaque = alpha > (1.0 / 255.0) + if opaque.all() or not opaque.any(): + return rgb + + bleed_px = max(0, int(round(float(bleed_px)))) + try: + from scipy import ndimage + + distance, indices = ndimage.distance_transform_edt( + ~opaque, + return_distances=True, + return_indices=True, + ) + fill = (~opaque) if bleed_px == 0 else ((~opaque) & (distance <= bleed_px)) + rgb[fill] = rgb[indices[0][fill], indices[1][fill]] + except Exception: + # Fallback for environments without scipy: one-pixel dilation per pass. + tensor = torch.from_numpy(rgb).permute(2, 0, 1).unsqueeze(0) + known = torch.from_numpy(opaque).view(1, 1, *opaque.shape) + passes = bleed_px if bleed_px > 0 else max(opaque.shape) + kernel = torch.ones((1, 1, 3, 3), dtype=torch.float32) + for _ in range(passes): + expanded = torch.nn.functional.conv2d(known.float(), kernel, padding=1) > 0 + new_pixels = expanded & ~known + if not new_pixels.any(): + break + neighbor_count = torch.nn.functional.conv2d(known.float(), kernel, padding=1).clamp_min(1.0) + summed = torch.cat([ + torch.nn.functional.conv2d((tensor[:, c:c + 1] * known.float()), kernel, padding=1) + for c in range(3) + ], dim=1) + averaged = summed / neighbor_count + tensor = torch.where(new_pixels.expand_as(tensor), averaged, tensor) + known = expanded + rgb = tensor.squeeze(0).permute(1, 2, 0).numpy() + return rgb + + +def _pil_to_ltxv_vae_image_tensor(image, alpha_rgb_mode="preserve_rgb", alpha_rgb_bleed_px=64): + mode = str(alpha_rgb_mode or "bleed_opaque") + if "A" not in image.getbands() or mode == "preserve_rgb": + return _pil_to_comfy_image_tensor(image.convert("RGB")) + + if mode == "bleed_opaque": + return torch.from_numpy(_etk_rgb_with_alpha_bleed(image, alpha_rgb_bleed_px)) + + rgba = image.convert("RGBA") + array = np.asarray(rgba, dtype=np.float32) / 255.0 + rgb = array[:, :, :3] + alpha = array[:, :, 3:4] + backgrounds = { + "composite_black": np.array([0.0, 0.0, 0.0], dtype=np.float32), + "composite_gray": np.array([0.5, 0.5, 0.5], dtype=np.float32), + "composite_white": np.array([1.0, 1.0, 1.0], dtype=np.float32), + } + background = backgrounds.get(mode) + if background is None: + background = backgrounds["composite_black"] + return torch.from_numpy((rgb * alpha) + (background * (1.0 - alpha))) + + +def _etk_unwrap_single_input(value): + while isinstance(value, (list, tuple)) and len(value) == 1: + value = value[0] + return value + + +def _etk_scalar_input(value): + value = _etk_unwrap_single_input(value) + while isinstance(value, (list, tuple)): + if not value: + return None + value = _etk_unwrap_single_input(value[0]) + return value + + +def _comfy_image_tensor_batch_to_pil(images): + if images is None: + return [] + if isinstance(images, (list, tuple)): + pil_images = [] + for item in images: + pil_images.extend(_comfy_image_tensor_batch_to_pil(item)) + return pil_images + if not torch.is_tensor(images): + raise ValueError("connected timeline images input must be an IMAGE tensor") + tensor = images.detach().cpu().float().clamp(0.0, 1.0) + if tensor.ndim == 3: + tensor = tensor.unsqueeze(0) + if tensor.ndim != 4: + raise ValueError(f"connected timeline images must have shape [B,H,W,C], got {tuple(tensor.shape)}") + if tensor.shape[-1] not in (1, 3, 4): + raise ValueError("connected timeline images must have 1, 3, or 4 channels") + + pil_images = [] + for image in tensor: + array = (image.numpy() * 255.0).round().astype(np.uint8) + channels = array.shape[-1] + if channels == 1: + pil_images.append(Image.fromarray(array[:, :, 0], mode="L").convert("RGBA")) + elif channels == 3: + pil_images.append(Image.fromarray(array, mode="RGB")) + else: + pil_images.append(Image.fromarray(array, mode="RGBA")) + return pil_images + + +def _etk_save_timeline_connected_image_preview(image, slot): + input_dir = folder_paths.get_input_directory() + subfolder = "ETKTimeline" + output_dir = os.path.join(input_dir, subfolder) + os.makedirs(output_dir, exist_ok=True) + + preview = ImageOps.exif_transpose(image).convert("RGBA") + digest = hashlib.sha256(np.asarray(preview, dtype=np.uint8).tobytes()).hexdigest()[:16] + filename = f"etk_timeline_input_slot_{int(slot):04d}_{digest}.png" + preview.save(os.path.join(output_dir, filename), compress_level=4) + return { + "filename": filename, + "subfolder": subfolder, + "type": "input", + } + + +def _etk_attach_timeline_connected_image(keyframe, image): + keyframe["_input_image"] = image + image_info = _etk_save_timeline_connected_image_preview(image, keyframe["slot"]) + keyframe["image"] = image_info + layers = [layer for layer in keyframe.get("layers", []) if isinstance(layer, dict)] + selected_layer_id = str(keyframe.get("selected_layer_id") or keyframe.get("selectedLayerId") or "") + layer_source = None + if selected_layer_id: + layer_source = next((layer for layer in layers if str(layer.get("id", "")) == selected_layer_id), None) + if layer_source is None and layers: + layer_source = layers[0] + layer_source = layer_source or {} + keyframe["layers"] = [{ + "id": str(layer_source.get("id") or selected_layer_id or "input"), + "image": image_info, + "fit_mode": layer_source.get("fit_mode", keyframe.get("fit_mode", "crop")), + "pan_x": float(layer_source.get("pan_x", keyframe.get("pan_x", 0.0))), + "pan_y": float(layer_source.get("pan_y", keyframe.get("pan_y", 0.0))), + "zoom": float(layer_source.get("zoom", keyframe.get("zoom", 1.0))), + "brightness": float(layer_source.get("brightness", keyframe.get("brightness", 1.0))), + "contrast": float(layer_source.get("contrast", keyframe.get("contrast", 1.0))), + }] + return keyframe + + +def _etk_timeline_with_connected_images( + keyframes, + images, + latent_frames, + require_image_index=False, +): + connected_images = _comfy_image_tensor_batch_to_pil(images) + if not connected_images: + return keyframes + + latent_frames = max(1, int(latent_frames)) + merged = [dict(keyframe) for keyframe in keyframes] + used_image_indices = set() + for keyframe in merged: + explicit_index = keyframe.get("image_index", None) + if explicit_index is None: + continue + try: + explicit_index = int(explicit_index) + except (TypeError, ValueError) as exc: + raise ValueError(f"timeline keyframe at slot {keyframe.get('slot')} has invalid image_index") from exc + if explicit_index < 0 or explicit_index >= len(connected_images): + raise ValueError( + f"prompt_schedule image_index {explicit_index} is outside the connected image range " + f"0..{len(connected_images) - 1}" + ) + _etk_attach_timeline_connected_image(keyframe, connected_images[explicit_index]) + used_image_indices.add(explicit_index) + + if require_image_index: + missing = [keyframe for keyframe in merged if keyframe.get("image_index", None) is None] + if missing: + raise ValueError( + "When prompt_schedule and images are connected, each schedule tuple must start " + "with a zero-based connected image index: " + "(image_index, seconds, positive_prompt, latent_strength, prompt_strength)." + ) + return sorted(merged, key=lambda item: item["slot"]) + + remaining_images = [ + image for index, image in enumerate(connected_images) + if index not in used_image_indices + ] + remaining_index = 0 + for keyframe in merged: + if remaining_index >= len(remaining_images): + break + if (keyframe.get("image") and not keyframe.get("input_image")) or keyframe.get("_input_image") is not None: + continue + _etk_attach_timeline_connected_image(keyframe, remaining_images[remaining_index]) + remaining_index += 1 + + if remaining_index >= len(remaining_images): + return sorted(merged, key=lambda item: item["slot"]) + + occupied = {int(keyframe["slot"]) for keyframe in merged if 0 <= int(keyframe["slot"]) < latent_frames} + cursor = 0 + if merged: + cursor = max(int(keyframe["slot"]) for keyframe in merged) + 1 + for image in remaining_images[remaining_index:]: + slot = None + for candidate in range(cursor, latent_frames): + if candidate not in occupied: + slot = candidate + break + if slot is None: + raise ValueError( + f"connected timeline images provide {len(connected_images)} images, " + f"but there are only {max(0, latent_frames - remaining_index)} free latent slots " + "at or after the existing timeline keyframes" + ) + occupied.add(slot) + cursor = slot + 1 + merged.append( + _etk_attach_timeline_connected_image( + { + "frame": slot * 8, + "slot": slot, + "fit_mode": "crop", + "pan_x": 0.0, + "pan_y": 0.0, + "zoom": 1.0, + "image": None, + "layers": [], + "positive_prompt": "", + "negative_prompt": "", + }, + image, + ) + ) + + return sorted(merged, key=lambda item: item["slot"]) + + + + +def _etk_timeline_result_with_ui(result, keyframes, force_ui=False, settings=None, ui_instance_id=None): + if ( + settings is None + and not force_ui + and not any(keyframe.get("_input_image") is not None for keyframe in keyframes) + ): + return result + instance_id = str(ui_instance_id or "") + keyframes_payload = { + "schemaVersion": ETK_LTXV_TIMELINE_SCHEMA_VERSION, + "ui_instance_id": instance_id, + "keyframes": _etk_timeline_keyframes_for_ui(keyframes, fps=(settings or {}).get("fps", 25.0)), + } + ui = { + "etk_ltxv_timeline_keyframes": [ + json.dumps(keyframes_payload), + ], + } + if settings is not None: + settings_payload = dict(settings) + settings_payload["ui_instance_id"] = instance_id + ui["etk_ltxv_timeline_settings"] = [json.dumps(settings_payload)] + return { + "ui": ui, + "result": result, + } + + +def _etk_send_timeline_settings_to_ui(unique_id, settings, ui_instance_id=None): + if unique_id is None or settings is None: + return + try: + from server import PromptServer + + payload = dict(settings) + payload["ui_instance_id"] = str(ui_instance_id or "") + PromptServer.instance.send_sync( + "etk_ltxv_timeline_settings", + { + "node": str(unique_id), + "settings": payload, + }, + getattr(PromptServer.instance, "client_id", None), + ) + except Exception: + pass + + +def _etk_timeline_json_like(value): + if isinstance(value, (list, tuple, dict)): + return True + if not isinstance(value, str): + return False + stripped = value.strip() + return stripped == "" or stripped.startswith("[") or stripped.startswith("{") + + +def _etk_timeline_editor_compat_inputs(timeline_json, strength): + if _etk_timeline_json_like(strength): + if not _etk_timeline_json_like(timeline_json): + timeline_json, strength = strength, timeline_json + else: + if timeline_json in (None, "", "[]"): + timeline_json = strength + strength = 1.0 + try: + strength = max(0.0, min(1.0, float(strength))) + except (TypeError, ValueError): + strength = 1.0 + if timeline_json is None: + timeline_json = "[]" + return timeline_json, strength diff --git a/timeline/rendering.py b/timeline/rendering.py new file mode 100644 index 0000000..cef5509 --- /dev/null +++ b/timeline/rendering.py @@ -0,0 +1,357 @@ +"""Layer rendering, compositing, alpha conversion, and unedited-source output.""" + +import os + +import torch +from PIL import Image, ImageColor, ImageDraw, ImageEnhance, ImageFilter, ImageFont, ImageOps + +from .images import ( + _comfy_image_tensor_batch_to_pil, + _fit_etk_timeline_image, + _pil_to_comfy_image_tensor, + _resolve_etk_timeline_image_path, +) +from .schema import ( + _etk_normalize_timeline_text_align, + _etk_normalize_timeline_text_color, +) + + +def _etk_file_info_key(image_info): + if isinstance(image_info, str): + return (image_info, "", "input") + if isinstance(image_info, dict): + return ( + image_info.get("filename") or image_info.get("name") or "", + image_info.get("subfolder") or "", + image_info.get("type") or "input", + ) + return None + + +def _load_etk_timeline_layer_source(keyframe, layer): + if keyframe.get("_input_image") is not None and _etk_file_info_key(layer.get("image")) == _etk_file_info_key(keyframe.get("image")): + return ImageOps.exif_transpose(keyframe["_input_image"]).copy() + image_path = _resolve_etk_timeline_image_path(layer["image"]) + with Image.open(image_path) as loaded: + return ImageOps.exif_transpose(loaded).copy() + + +def _apply_etk_timeline_filters(image, brightness=1.0, contrast=1.0): + brightness = max(0.0, min(2.0, float(brightness))) + contrast = max(0.0, min(2.0, float(contrast))) + if abs(brightness - 1.0) < 1e-6 and abs(contrast - 1.0) < 1e-6: + return image + + has_alpha = image.mode in {"RGBA", "LA"} or "transparency" in image.info + if has_alpha: + rgba = image.convert("RGBA") + alpha = rgba.getchannel("A") + rgb = rgba.convert("RGB") + rgb = ImageEnhance.Brightness(rgb).enhance(brightness) + rgb = ImageEnhance.Contrast(rgb).enhance(contrast) + filtered = Image.merge("RGBA", (*rgb.split(), alpha)) + return filtered + + filtered = image.convert("RGB") + filtered = ImageEnhance.Brightness(filtered).enhance(brightness) + filtered = ImageEnhance.Contrast(filtered).enhance(contrast) + return filtered + + +def _etk_timeline_text_font_candidates(font_family, bold=False, italic=False): + requested = str(font_family or "").strip() + if requested and os.path.isfile(requested): + yield requested + aliases = { + "sans-serif": "dejavu sans", + "sans": "dejavu sans", + "serif": "dejavu serif", + "monospace": "dejavu sans mono", + "mono": "dejavu sans mono", + } + target = aliases.get(requested.lower(), requested.lower()) + tokens = [token for token in target.replace("_", " ").replace("-", " ").split() if token] + font_roots = ["/usr/share/fonts", "/usr/local/share/fonts"] + scored = [] + for root in font_roots: + if not os.path.isdir(root): + continue + for dirpath, _dirnames, filenames in os.walk(root): + for filename in filenames: + if not filename.lower().endswith((".ttf", ".otf")): + continue + lower = filename.lower().replace("_", " ").replace("-", " ") + if tokens and not all(token in lower for token in tokens): + continue + has_bold = "bold" in lower + has_italic = "italic" in lower or "oblique" in lower + score = 0 + if bold == has_bold: + score += 10 + if italic == has_italic: + score += 10 + if not has_italic: + score += 1 + scored.append((score, os.path.join(dirpath, filename))) + for _score, path in sorted(scored, reverse=True): + yield path + + +def _etk_timeline_text_font(font_family, size, bold=False, italic=False): + size = max(1, int(round(float(size)))) + for candidate in _etk_timeline_text_font_candidates(font_family, bold, italic): + try: + return ImageFont.truetype(candidate, size) + except OSError: + continue + try: + return ImageFont.truetype(str(font_family or ""), size) + except OSError: + try: + return ImageFont.load_default(size=size) + except TypeError: + return ImageFont.load_default() + + +def _render_etk_timeline_text_layer(layer, width, height): + canvas = Image.new("RGBA", (int(width), int(height)), (0, 0, 0, 0)) + text = str(layer.get("text", "") or "") + if not text: + return canvas + font = _etk_timeline_text_font( + layer.get("font_family", "sans-serif"), + layer.get("font_size", 72), + layer.get("bold", False), + layer.get("italic", False), + ) + draw = ImageDraw.Draw(canvas) + color = ImageColor.getcolor(_etk_normalize_timeline_text_color(layer.get("color", "#ffffff")), "RGBA") + outline_width = max(0, int(round(float(layer.get("outline_width", 0) or 0)))) + outline_color = ImageColor.getcolor(_etk_normalize_timeline_text_color(layer.get("outline_color", "#000000"), "#000000"), "RGBA") + shadow_color = ImageColor.getcolor(_etk_normalize_timeline_text_color(layer.get("shadow_color", "#000000"), "#000000"), "RGBA") + shadow_blur = max(0.0, float(layer.get("shadow_blur", 0) or 0)) + shadow_offset_x = float(layer.get("shadow_offset_x", 0) or 0) + shadow_offset_y = float(layer.get("shadow_offset_y", 0) or 0) + align = _etk_normalize_timeline_text_align(layer.get("align", "center")) + lines = text.splitlines() or [text] + boxes = [draw.textbbox((0, 0), line if line else " ", font=font, stroke_width=outline_width) for line in lines] + heights = [max(1, bottom - top) for _left, top, _right, bottom in boxes] + line_step = max(1, int(round(max(heights) * 1.18))) + total_height = line_step * len(lines) + y = (height - total_height) / 2 + placements = [] + for line, box in zip(lines, boxes): + left, top, right, _bottom = box + text_width = right - left + if align == "left": + x = -left + elif align == "right": + x = width - text_width - left + else: + x = (width - text_width) / 2 - left + placements.append((line, x, y - top)) + y += line_step + + if shadow_color[3] > 0 and (shadow_blur > 0 or shadow_offset_x or shadow_offset_y): + shadow_layer = Image.new("RGBA", (int(width), int(height)), (0, 0, 0, 0)) + shadow_draw = ImageDraw.Draw(shadow_layer) + for line, x, text_y in placements: + shadow_draw.text( + (x + shadow_offset_x, text_y + shadow_offset_y), + line, + font=font, + fill=shadow_color, + stroke_width=outline_width, + stroke_fill=shadow_color, + ) + if shadow_blur > 0: + shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_blur)) + canvas = Image.alpha_composite(canvas, shadow_layer) + draw = ImageDraw.Draw(canvas) + + for line, x, text_y in placements: + draw.text( + (x, text_y), + line, + font=font, + fill=color, + stroke_width=outline_width, + stroke_fill=outline_color, + ) + return canvas + + +def _fit_etk_timeline_keyframe_image(keyframe, width, height): + layers = [ + layer for layer in keyframe.get("layers", []) + if layer.get("image") or layer.get("type") == "text" + ] + if layers: + composite = Image.new("RGBA", (width, height), (0, 0, 0, 0)) + used_layer = False + for layer in layers: + if layer.get("type") == "text": + source = _render_etk_timeline_text_layer(layer, width, height) + else: + source = _load_etk_timeline_layer_source(keyframe, layer) + source = _apply_etk_timeline_filters( + source, + layer.get("brightness", 1.0), + layer.get("contrast", 1.0), + ) + fitted = _fit_etk_timeline_image( + source, + width, + height, + layer.get("fit_mode", "crop"), + layer.get("pan_x", 0.0), + layer.get("pan_y", 0.0), + layer.get("zoom", 1.0), + transparent_pad=True, + ) + composite = Image.alpha_composite(composite, fitted.convert("RGBA")) + used_layer = True + return composite if used_layer else None + + connected_image = keyframe.get("_input_image") + if connected_image is not None: + return _fit_etk_timeline_image( + _apply_etk_timeline_filters(connected_image, keyframe.get("brightness", 1.0), keyframe.get("contrast", 1.0)), + width, + height, + keyframe["fit_mode"], + keyframe["pan_x"], + keyframe["pan_y"], + keyframe["zoom"], + ) + if not keyframe["image"]: + return None + image_path = _resolve_etk_timeline_image_path(keyframe["image"]) + with Image.open(image_path) as loaded: + return _fit_etk_timeline_image( + _apply_etk_timeline_filters(loaded, keyframe.get("brightness", 1.0), keyframe.get("contrast", 1.0)), + width, + height, + keyframe["fit_mode"], + keyframe["pan_x"], + keyframe["pan_y"], + keyframe["zoom"], + ) + + +def _etk_passthrough_connected_image_list(images): + if images is None: + return None + if not torch.is_tensor(images): + return None + tensor = images.detach().clone().float().clamp(0.0, 1.0) + if tensor.ndim == 3: + tensor = tensor.unsqueeze(0) + if tensor.ndim != 4: + raise ValueError(f"connected timeline images must have shape [B,H,W,C], got {tuple(tensor.shape)}") + if tensor.shape[-1] == 1: + tensor = tensor.expand(*tensor.shape[:-1], 3).clone() + if tensor.shape[-1] == 4: + tensor = tensor[..., :3].clone() + if tensor.shape[-1] != 3: + raise ValueError("connected timeline images must have 1, 3, or 4 channels") + return [image.unsqueeze(0).contiguous() for image in tensor] + + +def _etk_passthrough_connected_image_batch(images): + image_list = _etk_passthrough_connected_image_list(images) + if image_list is None: + return None + return torch.cat(image_list, dim=0).contiguous() + + +def _etk_unedited_source_image_tensor(image, width, height): + fitted = _fit_etk_timeline_image( + ImageOps.exif_transpose(image), + width, + height, + "pad", + ) + return _pil_to_comfy_image_tensor(fitted.convert("RGBA")).unsqueeze(0).contiguous() + + +def _etk_connected_unedited_source_batch(images, width, height): + pil_images = _comfy_image_tensor_batch_to_pil(images) + if not pil_images: + return None + return torch.cat( + [_etk_unedited_source_image_tensor(image, width, height) for image in pil_images], + dim=0, + ).contiguous() + + +def _load_etk_timeline_keyframe_unedited_sources(keyframe, width=None, height=None): + layers = [ + layer for layer in keyframe.get("layers", []) + if layer.get("image") or layer.get("type") == "text" + ] + if layers: + sources = [] + for layer in layers: + if layer.get("type") == "text": + if width is None or height is None: + continue + sources.append(_render_etk_timeline_text_layer(layer, width, height)) + else: + sources.append(_load_etk_timeline_layer_source(keyframe, layer)) + return sources + + if keyframe.get("_input_image") is not None: + return [ImageOps.exif_transpose(keyframe["_input_image"]).copy()] + + if keyframe.get("image"): + image_path = _resolve_etk_timeline_image_path(keyframe["image"]) + with Image.open(image_path) as loaded: + return [ImageOps.exif_transpose(loaded).copy()] + + return [] + + +def _load_etk_timeline_keyframe_unedited_source(keyframe): + sources = _load_etk_timeline_keyframe_unedited_sources(keyframe) + return sources[0] if sources else None + + +def _etk_timeline_unedited_source_list(images, width, height): + if not images: + return [torch.zeros((1, height, width, 4), dtype=torch.float32)] + return [ + _etk_unedited_source_image_tensor(image, width, height) + for image in images + ] + + +def _etk_timeline_unedited_source_batch(images, width, height): + return torch.cat(_etk_timeline_unedited_source_list(images, width, height), dim=0).contiguous() + + +def _etk_timeline_image_batch(images, width, height): + if not images: + return torch.zeros((1, height, width, 3), dtype=torch.float32) + return torch.stack([_pil_to_comfy_image_tensor(image.convert("RGBA")) for image in images], dim=0) + + +def _etk_timeline_alpha_tensor(image): + tensor = _pil_to_comfy_image_tensor(image) + if tensor.ndim == 3 and tensor.shape[-1] >= 4: + return tensor[:, :, 3].clamp(0.0, 1.0) + return torch.ones((image.height, image.width), dtype=torch.float32) + + +def _etk_alpha_noise_mask(image, strength, latent_height, latent_width, device, dtype): + alpha = _etk_timeline_alpha_tensor(image) + if torch.all(alpha >= 1.0): + return None + alpha = alpha.to(device=device, dtype=dtype).view(1, 1, 1, image.height, image.width) + alpha = torch.nn.functional.interpolate( + alpha.view(1, 1, image.height, image.width), + size=(latent_height, latent_width), + mode="area", + ).view(1, 1, 1, latent_height, latent_width) + return (1.0 - (float(strength) * alpha)).clamp(0.0, 1.0) diff --git a/timeline/schema.py b/timeline/schema.py new file mode 100644 index 0000000..71e093c --- /dev/null +++ b/timeline/schema.py @@ -0,0 +1,257 @@ +"""Timeline JSON parsing and normalized layer/keyframe schema.""" + +import json + +from PIL import ImageColor + +from .core import ( + ETK_LTXV_TIMELINE_SCHEMA_VERSION, + _etk_ltxv_timeline_payload_keyframes, +) + + +def _etk_bool(value, fallback=False): + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"true", "1", "yes", "bold"}: + return True + if lowered in {"false", "0", "no", "normal"}: + return False + if isinstance(value, (int, float)): + return value != 0 + return bool(fallback) + + +ETK_TIMELINE_TEXT_LAYER_FIELDS = frozenset({ + "text", + "fontFamily", + "font_family", + "fontSize", + "font_size", + "bold", + "italic", + "align", + "textAlign", + "text_align", + "color", + "outlineColor", + "outline_color", + "outlineWidth", + "outline_width", + "shadowColor", + "shadow_color", + "shadowBlur", + "shadow_blur", + "shadowOffsetX", + "shadow_offset_x", + "shadowOffsetY", + "shadow_offset_y", +}) + + +def _etk_timeline_layer_is_text(raw, fallback=None): + fallback = fallback or {} + if not isinstance(raw, dict): + return False + layer_type = str( + raw.get("type", raw.get("layerType", raw.get("layer_type", fallback.get("type", "")))) + or "" + ).lower() + return layer_type == "text" or any(field in raw for field in ETK_TIMELINE_TEXT_LAYER_FIELDS) + + +def _etk_normalize_timeline_text_color(value, fallback="#ffffff"): + text = str(value if value is not None else fallback).strip() + if not text: + text = fallback + try: + ImageColor.getcolor(text, "RGBA") + except ValueError: + text = fallback + return text + + +def _etk_float(value, fallback=0.0, minimum=None, maximum=None): + try: + number = float(value) + except (TypeError, ValueError): + number = float(fallback) + if minimum is not None: + number = max(float(minimum), number) + if maximum is not None: + number = min(float(maximum), number) + return number + + +def _etk_normalize_timeline_text_align(value, fallback="center"): + text = str(value if value is not None else fallback).strip().lower() + return text if text in {"left", "center", "right"} else fallback + + +def _etk_parse_timeline_layer(raw, fallback=None): + fallback = fallback or {} + if not isinstance(raw, dict): + return None + is_text_layer = _etk_timeline_layer_is_text(raw, fallback) + image_info = None if is_text_layer else raw.get("image") or raw.get("file") or raw.get("filename") or fallback.get("image") + has_layer_slot = bool( + image_info + or is_text_layer + or raw.get("id") + or raw.get("empty") + or "image" in raw + or "file" in raw + or "filename" in raw + or "type" in raw + or "layerType" in raw + or "layer_type" in raw + or any(field in raw for field in ETK_TIMELINE_TEXT_LAYER_FIELDS) + ) + if not has_layer_slot: + return None + fit_mode = str(raw.get("fitMode", raw.get("fit_mode", fallback.get("fit_mode", "crop")))).lower() + if fit_mode not in {"crop", "pad"}: + fit_mode = "crop" + try: + pan_x = float(raw.get("panX", raw.get("pan_x", fallback.get("pan_x", 0.0)))) + pan_y = float(raw.get("panY", raw.get("pan_y", fallback.get("pan_y", 0.0)))) + zoom = max(0.01, min(20.0, float(raw.get("zoom", fallback.get("zoom", 1.0))))) + brightness = max(0.0, min(2.0, float(raw.get("brightness", fallback.get("brightness", 1.0))))) + contrast = max(0.0, min(2.0, float(raw.get("contrast", fallback.get("contrast", 1.0))))) + except (TypeError, ValueError) as exc: + raise ValueError("timeline layer has invalid pan/zoom/filter values") from exc + parsed = { + "id": str(raw.get("id", fallback.get("id", "")) or ""), + "type": "text" if is_text_layer else "image", + "image": image_info, + "empty": not bool(image_info) and not is_text_layer, + "fit_mode": fit_mode, + "pan_x": pan_x, + "pan_y": pan_y, + "zoom": zoom, + "brightness": brightness, + "contrast": contrast, + } + if is_text_layer: + try: + font_size = max(1.0, min(512.0, float(raw.get("fontSize", raw.get("font_size", fallback.get("font_size", 72)))))) + except (TypeError, ValueError) as exc: + raise ValueError("timeline text layer has invalid font size") from exc + parsed.update({ + "text": str(raw.get("text", fallback.get("text", "Text")) or ""), + "font_family": str(raw.get("fontFamily", raw.get("font_family", fallback.get("font_family", "sans-serif"))) or "sans-serif"), + "font_size": font_size, + "bold": _etk_bool(raw.get("bold", fallback.get("bold", False))), + "italic": _etk_bool(raw.get("italic", fallback.get("italic", False))), + "align": _etk_normalize_timeline_text_align(raw.get("align", raw.get("textAlign", raw.get("text_align", fallback.get("align", "center"))))), + "color": _etk_normalize_timeline_text_color(raw.get("color", fallback.get("color", "#ffffff"))), + "outline_color": _etk_normalize_timeline_text_color(raw.get("outlineColor", raw.get("outline_color", fallback.get("outline_color", "#000000"))), "#000000"), + "outline_width": _etk_float(raw.get("outlineWidth", raw.get("outline_width", fallback.get("outline_width", 0.0))), 0.0, 0.0, 64.0), + "shadow_color": _etk_normalize_timeline_text_color(raw.get("shadowColor", raw.get("shadow_color", fallback.get("shadow_color", "#000000"))), "#000000"), + "shadow_blur": _etk_float(raw.get("shadowBlur", raw.get("shadow_blur", fallback.get("shadow_blur", 0.0))), 0.0, 0.0, 128.0), + "shadow_offset_x": _etk_float(raw.get("shadowOffsetX", raw.get("shadow_offset_x", fallback.get("shadow_offset_x", 0.0))), 0.0, -512.0, 512.0), + "shadow_offset_y": _etk_float(raw.get("shadowOffsetY", raw.get("shadow_offset_y", fallback.get("shadow_offset_y", 0.0))), 0.0, -512.0, 512.0), + }) + return parsed + + +def _parse_etk_ltxv_timeline(timeline_json, include_empty=False, fps=25.0): + if not timeline_json: + return [] + try: + fps = max(0.001, float(fps)) + except (TypeError, ValueError) as exc: + raise ValueError("timeline fps must be a number") from exc + try: + payload = json.loads(timeline_json) + except json.JSONDecodeError as exc: + raise ValueError(f"timeline_json is not valid JSON: {exc}") from exc + + payload, schema_version = _etk_ltxv_timeline_payload_keyframes(payload) + + keyframes = [] + for raw in payload: + if not isinstance(raw, dict): + continue + image_info = raw.get("image") or raw.get("file") or raw.get("filename") + positive_prompt = str(raw.get("positivePrompt", raw.get("positive_prompt", "")) or "") + negative_prompt = str(raw.get("negativePrompt", raw.get("negative_prompt", "")) or "") + if schema_version == ETK_LTXV_TIMELINE_SCHEMA_VERSION and raw.get("time") is None: + raise ValueError("timeline_json schemaVersion 2 keyframes must include time") + try: + if raw.get("time") is not None: + time_seconds = float(raw.get("time", 0.0)) + elif raw.get("slot") is not None: + time_seconds = float(raw.get("slot", 0.0)) * 8.0 / fps + elif raw.get("frame") is not None: + time_seconds = float(raw.get("frame", 0.0)) / fps + else: + time_seconds = 0.0 + frame = int(round(time_seconds * fps)) + slot = int((frame / 8.0) + 0.5) + except (TypeError, ValueError) as exc: + raise ValueError(f"timeline keyframe has invalid time value: {raw.get('time')}") from exc + if time_seconds < 0 or slot < 0: + raise ValueError(f"timeline keyframe time {time_seconds} maps outside video latent range") + fit_mode = str(raw.get("fitMode", raw.get("fit_mode", "crop"))).lower() + if fit_mode not in {"crop", "pad"}: + fit_mode = "crop" + try: + pan_x = float(raw.get("panX", raw.get("pan_x", 0.0))) + pan_y = float(raw.get("panY", raw.get("pan_y", 0.0))) + zoom = max(0.01, min(20.0, float(raw.get("zoom", 1.0)))) + brightness = max(0.0, min(2.0, float(raw.get("brightness", 1.0)))) + contrast = max(0.0, min(2.0, float(raw.get("contrast", 1.0)))) + prompt_strength = max(0.0, min(4.0, float(raw.get("promptStrength", raw.get("prompt_strength", 1.0))))) + latent_strength_raw = raw.get("latentStrength", raw.get("latent_strength", raw.get("strength", None))) + latent_strength = None if latent_strength_raw is None else max(0.0, min(1.0, float(latent_strength_raw))) + except (TypeError, ValueError) as exc: + raise ValueError("timeline keyframe has invalid pan/zoom/filter values") from exc + fallback_layer = { + "image": image_info, + "fit_mode": fit_mode, + "pan_x": pan_x, + "pan_y": pan_y, + "zoom": zoom, + "brightness": brightness, + "contrast": contrast, + } + layers = [] + if isinstance(raw.get("layers"), list): + for layer in raw.get("layers", []): + parsed_layer = _etk_parse_timeline_layer(layer, {**fallback_layer, "image": None}) + if parsed_layer is not None: + layers.append(parsed_layer) + if not layers and image_info: + parsed_layer = _etk_parse_timeline_layer({"image": image_info}, fallback_layer) + if parsed_layer is not None: + layers.append(parsed_layer) + if layers: + image_info = layers[0]["image"] + if not include_empty and not layers and not positive_prompt and not negative_prompt: + continue + keyframes.append( + { + "frame": frame, + "slot": slot, + "time": time_seconds, + "fit_mode": fit_mode, + "pan_x": pan_x, + "pan_y": pan_y, + "zoom": zoom, + "brightness": brightness, + "contrast": contrast, + "image": image_info, + "layers": layers, + "selected_layer_id": str(raw.get("selectedLayerId", raw.get("selected_layer_id", "")) or ""), + "input_image": bool(raw.get("inputImage", raw.get("input_image", False))), + "positive_prompt": positive_prompt, + "negative_prompt": negative_prompt, + "prompt_strength": prompt_strength, + "latent_strength": latent_strength, + } + ) + + return sorted(keyframes, key=lambda item: item["slot"]) diff --git a/web/docs/ETKLTXVTimelineImageEditor.md b/web/docs/ETKLTXVTimelineImageEditor.md new file mode 100644 index 0000000..e31af91 --- /dev/null +++ b/web/docs/ETKLTXVTimelineImageEditor.md @@ -0,0 +1,29 @@ +# ETK LTXV Timeline Image Editor + +Build an LTXV/LTX 2.x image-guide timeline with composited image and text +layers, per-keyframe prompts, strengths, and frame positions. + +## Inputs + +- `width`, `height`: Output image dimensions, rounded to multiples of 32. +- `length`: Video frame count. LTX-compatible values are one plus a multiple + of eight. +- `timeline_json`: Serialized editor state. The browser editor maintains this + value automatically. +- `fps`: Timeline frame rate used to convert frames to seconds. +- `images`: Optional connected image batch used to populate keyframes. +- `prompts`, `strengths`, `times`: Optional aligned lists that replace the + corresponding keyframe metadata by index. + +## Outputs + +- `edited_images`: Composited RGB keyframe images. +- `unedited_images`: Matching RGBA source images before editor effects. +- `width`, `height`, `length`: Normalized timeline dimensions. +- `strengths`: Per-keyframe image-guide strengths. +- `frame_indexes`: Video-frame positions. +- `position_seconds`: Positions derived from the frame rate. +- `prompts`: Per-keyframe positive prompts. + +The node performs image editing only. It does not load or execute a model, +text encoder, CLIP, or VAE. diff --git a/web/etk_ltxv_timeline_image_editor.js b/web/etk_ltxv_timeline_image_editor.js new file mode 100755 index 0000000..d8385bf --- /dev/null +++ b/web/etk_ltxv_timeline_image_editor.js @@ -0,0 +1,12 @@ +import "./ltxv_timeline/extension.js"; +import { + applyBackendTimelineSettingsToNode, + ensureUiInstanceId, + payloadsMatchNode, +} from "./ltxv_timeline/runtime.js"; + +export { + applyBackendTimelineSettingsToNode, + ensureUiInstanceId, + payloadsMatchNode, +}; diff --git a/web/ltxv_timeline/constants.js b/web/ltxv_timeline/constants.js new file mode 100644 index 0000000..3c652e5 --- /dev/null +++ b/web/ltxv_timeline/constants.js @@ -0,0 +1,119 @@ +// Shared constants and schema defaults for the timeline editor. +export const IMAGE_TIMELINE_NODES = new Set([ + "ETKLTXVTimelineImageEditor", +]); +export const TARGET_NODES = IMAGE_TIMELINE_NODES; +export const CUSTOM_AREA_TOP = 186; +export const TIMELINE_TOP_PAD = 34; +export const TIMELINE_TRACK_HEIGHT = 30; +export const TIMELINE_GAP = 20; +export const TIMELINE_CONTROLS_HEIGHT = 126; +export const TIMELINE_CONTROLS_GAP = 10; +export const TIMELINE_BOTTOM_PAD = 20; +export const PREVIEW_MIN_HEIGHT = 336; +export const TIMELINE_EDITOR_WIDGET = "__etk_ltxv_timeline_editor"; +export const KEYFRAME_POSITIVE_PROMPT_WIDGET = "__etk_ltxv_keyframe_positive_prompt"; +export const TEXT_LAYER_EDITOR_WIDGET = "__etk_ltxv_text_layer_editor"; +export const UPLOAD_MODE_PROPERTY = "etk_ltxv_upload_mode"; +export const LAYER_LOCK_PROPERTY = "etk_ltxv_timeline_layers_locked"; +export const CONTROL_TAB_PROPERTY = "etk_ltxv_controls_tab"; +export const CANVAS_SIZE_PROPERTY = "etk_ltxv_timeline_canvas_size"; +export const OUTPUT_SETTINGS_PROPERTY = "etk_ltxv_timeline_output_settings"; +export const CONTROL_TABS = [ + { id: "strength", label: "Strength", w: 72 }, + { id: "layers", label: "Layers", w: 62 }, + { id: "filters", label: "Filters", w: 62 }, +]; +export const CONTROL_HEADER_HEIGHT = 24; +export const CONTROL_TABS_HEIGHT = 22; +export const TIMELINE_EDITOR_HEIGHT = + TIMELINE_TOP_PAD + TIMELINE_TRACK_HEIGHT + TIMELINE_GAP + TIMELINE_CONTROLS_HEIGHT + TIMELINE_CONTROLS_GAP + PREVIEW_MIN_HEIGHT + TIMELINE_BOTTOM_PAD; +export const KEYFRAME_PROMPT_EDITOR_HEIGHT = 98; +export const KEYFRAME_PROMPT_EDITOR_MAX_HEIGHT = 220; +export const TEXT_LAYER_EDITOR_HEIGHT = 164; +export const TEXT_LAYER_EDITOR_MAX_HEIGHT = 250; +export const UI_INSTANCE_WIDGET = "ui_instance_id"; +export const UI_INSTANCE_PROPERTY = "etk_ltxv_timeline_ui_instance_id"; +export const MIN_LAYER_ZOOM = 0.01; +export const MAX_LAYER_ZOOM = 20; +export const WHEEL_ZOOM_STEP = 0.05; +export const SHIFT_WHEEL_ZOOM_STEP = 0.02; +export const CTRL_WHEEL_ZOOM_STEP = 0.002; +export const KEYBOARD_PAN_STEP = 0.05; +export const CTRL_KEYBOARD_PAN_IMAGE_PX = 1; +export const FIXED_PROMPT_WIDGET_NAMES = new Set(["global_positive_prompt", "global_negative_prompt", "prompt_schedule"]); +export const PROMPT_WIDGET_FIXED_HEIGHT = 72; +export const CHAIN_INPUT_NAME = "chain_in"; +export const LOCAL_MEDIA_MANAGER_DROP_TYPE = "application/x-local-media-manager-image"; +export const CHAIN_INHERITED_WIDGET_NAMES = new Set([ + "width", + "height", + "global_positive_prompt", + "global_negative_prompt", + "positive_audio_prompt_weight", + "negative_audio_prompt_weight", +]); +export const TIMELINE_GLOBAL_LISTENER_STATE = "__etkTimelineGlobalListenerState"; +export const TIMELINE_GLOBAL_LISTENER_TOKEN = {}; +export const TIMELINE_STATUS_DURATION_MS = 4500; +export const TIMELINE_SCHEMA_VERSION = 2; +export const DEFAULT_TEXT_LAYER = Object.freeze({ + text: "Text", + fontFamily: "sans-serif", + fontSize: 72, + bold: false, + italic: false, + align: "center", + color: "#ffffff", + outlineColor: "#000000", + outlineWidth: 0, + shadowColor: "#000000", + shadowBlur: 0, + shadowOffsetX: 0, + shadowOffsetY: 0, +}); + +export const TEXT_LAYER_FONT_OPTIONS = Object.freeze([ + ["sans-serif", "Sans"], + ["serif", "Serif"], + ["monospace", "Mono"], + ["Arial", "Arial"], + ["Helvetica", "Helvetica"], + ["Verdana", "Verdana"], + ["Georgia", "Georgia"], + ["Times New Roman", "Times"], + ["Courier New", "Courier"], + ["Impact", "Impact"], + ["DejaVu Sans", "DejaVu Sans"], + ["DejaVu Serif", "DejaVu Serif"], + ["DejaVu Sans Mono", "DejaVu Mono"], + ["Liberation Sans", "Liberation Sans"], + ["Liberation Serif", "Liberation Serif"], + ["Liberation Mono", "Liberation Mono"], +]); + +export const TEXT_LAYER_FIELDS = Object.freeze([ + "text", + "fontFamily", + "font_family", + "fontSize", + "font_size", + "bold", + "italic", + "align", + "textAlign", + "text_align", + "color", + "outlineColor", + "outline_color", + "outlineWidth", + "outline_width", + "shadowColor", + "shadow_color", + "shadowBlur", + "shadow_blur", + "shadowOffsetX", + "shadow_offset_x", + "shadowOffsetY", + "shadow_offset_y", +]); diff --git a/web/ltxv_timeline/editors.js b/web/ltxv_timeline/editors.js new file mode 100644 index 0000000..99db4cd --- /dev/null +++ b/web/ltxv_timeline/editors.js @@ -0,0 +1,717 @@ +// Prompt and text-layer DOM editors plus widget synchronization. +import { + CHAIN_INHERITED_WIDGET_NAMES, + CHAIN_INPUT_NAME, + DEFAULT_TEXT_LAYER, + FIXED_PROMPT_WIDGET_NAMES, + KEYFRAME_POSITIVE_PROMPT_WIDGET, + KEYFRAME_PROMPT_EDITOR_HEIGHT, + KEYFRAME_PROMPT_EDITOR_MAX_HEIGHT, + PROMPT_WIDGET_FIXED_HEIGHT, + TEXT_LAYER_EDITOR_HEIGHT, + TEXT_LAYER_EDITOR_MAX_HEIGHT, + TEXT_LAYER_EDITOR_WIDGET, + TEXT_LAYER_FONT_OPTIONS, + TIMELINE_EDITOR_WIDGET, +} from "./constants.js"; +import { getInput, getWidget, setTimelineStatus } from "./runtime.js"; +import { + activeLayer, + ensureTextLayerFontOption, + isTextLayer, + layerEditingLocked, + normalizeKeyframes, + normalizePrompt, + normalizeTextLayerAlign, + normalizeTextLayerBold, + normalizeTextLayerColor, + normalizeTextLayerFontFamily, + normalizeTextLayerFontSize, + normalizeTextLayerItalic, + normalizeTextLayerNumber, + normalizeTextLayerString, + rejectLayerEditIfLocked, + selectedKeyframe, + stopDomEvent, + syncTimelineWidget, + updateActiveLayer, +} from "./model.js"; +import { applyTimelineCanvasSize } from "./node_setup.js"; +import { formatSlotSeconds } from "./timeline_ops.js"; +import { editPromptModal } from "./rendering.js"; +export function timelineEditorWidget(node) { + return node.widgets ? node.widgets.find((widget) => widget && widget.name === TIMELINE_EDITOR_WIDGET) : null; +} + +export function keyframePromptEditorWidget(node) { + return node.widgets ? node.widgets.find((widget) => widget && widget.name === KEYFRAME_POSITIVE_PROMPT_WIDGET) : null; +} + +export function fixPromptWidgetHeights(node) { + for (const widget of node.widgets || []) { + if (!FIXED_PROMPT_WIDGET_NAMES.has(widget.name)) continue; + widget.etkTimelineFixedHeight = PROMPT_WIDGET_FIXED_HEIGHT; + widget.last_h = PROMPT_WIDGET_FIXED_HEIGHT; + widget.computeLayoutSize = () => ({ + minHeight: PROMPT_WIDGET_FIXED_HEIGHT, + maxHeight: PROMPT_WIDGET_FIXED_HEIGHT, + minWidth: 220, + }); + widget.computeSize = (width) => [width, PROMPT_WIDGET_FIXED_HEIGHT]; + if (widget.element) { + widget.element.rows = 3; + widget.element.style.height = `${PROMPT_WIDGET_FIXED_HEIGHT}px`; + widget.element.style.minHeight = `${PROMPT_WIDGET_FIXED_HEIGHT}px`; + widget.element.style.maxHeight = `${PROMPT_WIDGET_FIXED_HEIGHT}px`; + widget.element.style.resize = "none"; + } + } +} + +export function clarifyDefaultGuideStrengthWidget(node) { + const widget = getWidget(node, "strength"); + if (!widget || widget.etkTimelineGuideStrengthRelabeled) return; + widget.etkTimelineGuideStrengthRelabeled = true; + widget.label = "default image guide strength"; + if (!widget.options) widget.options = {}; + widget.options.label = "default image guide strength"; + widget.options.tooltip = "Default image/latent guide strength for keyframes that do not override latent strength."; +} + +export function migrateDisableRelayWidgetOrder(node) { + const testWidget = getWidget(node, "test_this_chunk_only"); + const disableWidget = getWidget(node, "disable_prompt_relay"); + const globalPositive = getWidget(node, "global_positive_prompt"); + const globalNegative = getWidget(node, "global_negative_prompt"); + const epsilon = getWidget(node, "epsilon"); + const timeline = getWidget(node, "timeline_json"); + const promptSchedule = getWidget(node, "prompt_schedule"); + const promptScheduleFps = getWidget(node, "prompt_schedule_fps"); + const alphaMaskMode = getWidget(node, "alpha_mask_mode"); + const alphaDownsampleMode = getWidget(node, "alpha_downsample_mode"); + const alphaKeepThreshold = getWidget(node, "alpha_keep_threshold"); + const alphaMaskGrowPx = getWidget(node, "alpha_mask_grow_px"); + const transparentMaskValue = getWidget(node, "transparent_mask_value"); + const opaqueMaskValue = getWidget(node, "opaque_mask_value"); + const sendPixelMaskAttention = getWidget(node, "send_pixel_mask_attention"); + const vaeAlphaRgbMode = getWidget(node, "vae_alpha_rgb_mode"); + const vaeAlphaBleedPx = getWidget(node, "vae_alpha_bleed_px"); + if (!testWidget || !disableWidget || !globalPositive || !globalNegative || !epsilon || !timeline) return false; + + const oldWithoutToggles = + typeof testWidget.value === "string" && + typeof disableWidget.value === "string" && + (typeof timeline.value === "number" || String(globalNegative.value || "").trim().startsWith("[")); + const oldWithTestOnly = + typeof testWidget.value === "boolean" && + typeof disableWidget.value === "string" && + typeof globalPositive.value === "string" && + typeof globalNegative.value !== "string"; + if (!oldWithoutToggles && !oldWithTestOnly) return false; + + if (oldWithoutToggles) { + const oldGlobalPositive = testWidget.value; + const oldGlobalNegative = disableWidget.value; + const oldEpsilon = globalPositive.value; + const oldTimeline = globalNegative.value; + const oldPromptSchedule = epsilon.value; + const oldPromptScheduleFps = timeline.value; + const oldAlphaMaskMode = promptSchedule ? promptSchedule.value : undefined; + const oldAlphaDownsampleMode = promptScheduleFps ? promptScheduleFps.value : undefined; + const oldAlphaKeepThreshold = alphaMaskMode ? alphaMaskMode.value : undefined; + const oldAlphaMaskGrowPx = alphaDownsampleMode ? alphaDownsampleMode.value : undefined; + const oldTransparentMaskValue = alphaKeepThreshold ? alphaKeepThreshold.value : undefined; + const oldOpaqueMaskValue = alphaMaskGrowPx ? alphaMaskGrowPx.value : undefined; + const oldSendPixelMaskAttention = transparentMaskValue ? transparentMaskValue.value : undefined; + const oldVaeAlphaRgbMode = opaqueMaskValue ? opaqueMaskValue.value : undefined; + const oldVaeAlphaBleedPx = sendPixelMaskAttention ? sendPixelMaskAttention.value : undefined; + + testWidget.value = false; + disableWidget.value = false; + globalPositive.value = oldGlobalPositive; + globalNegative.value = oldGlobalNegative; + epsilon.value = oldEpsilon; + timeline.value = oldTimeline; + if (promptSchedule) promptSchedule.value = oldPromptSchedule; + if (promptScheduleFps) promptScheduleFps.value = oldPromptScheduleFps; + if (alphaMaskMode) alphaMaskMode.value = oldAlphaMaskMode; + if (alphaDownsampleMode) alphaDownsampleMode.value = oldAlphaDownsampleMode; + if (alphaKeepThreshold) alphaKeepThreshold.value = oldAlphaKeepThreshold; + if (alphaMaskGrowPx) alphaMaskGrowPx.value = oldAlphaMaskGrowPx; + if (transparentMaskValue) transparentMaskValue.value = oldTransparentMaskValue; + if (opaqueMaskValue) opaqueMaskValue.value = oldOpaqueMaskValue; + if (sendPixelMaskAttention) sendPixelMaskAttention.value = oldSendPixelMaskAttention; + if (vaeAlphaRgbMode) vaeAlphaRgbMode.value = oldVaeAlphaRgbMode; + if (vaeAlphaBleedPx) vaeAlphaBleedPx.value = oldVaeAlphaBleedPx; + } else { + const oldDisableValue = disableWidget.value; + const oldGlobalPositive = globalPositive.value; + const oldGlobalNegative = globalNegative.value; + const oldEpsilon = epsilon.value; + const oldTimeline = timeline.value; + const oldPromptSchedule = promptSchedule ? promptSchedule.value : undefined; + const oldPromptScheduleFps = promptScheduleFps ? promptScheduleFps.value : undefined; + const oldAlphaMaskMode = alphaMaskMode ? alphaMaskMode.value : undefined; + const oldAlphaDownsampleMode = alphaDownsampleMode ? alphaDownsampleMode.value : undefined; + const oldAlphaKeepThreshold = alphaKeepThreshold ? alphaKeepThreshold.value : undefined; + const oldAlphaMaskGrowPx = alphaMaskGrowPx ? alphaMaskGrowPx.value : undefined; + const oldTransparentMaskValue = transparentMaskValue ? transparentMaskValue.value : undefined; + const oldOpaqueMaskValue = opaqueMaskValue ? opaqueMaskValue.value : undefined; + const oldSendPixelMaskAttention = sendPixelMaskAttention ? sendPixelMaskAttention.value : undefined; + const oldVaeAlphaRgbMode = vaeAlphaRgbMode ? vaeAlphaRgbMode.value : undefined; + + disableWidget.value = false; + globalPositive.value = oldDisableValue; + globalNegative.value = oldGlobalPositive; + epsilon.value = oldGlobalNegative; + timeline.value = oldEpsilon; + if (promptSchedule) promptSchedule.value = oldTimeline; + if (promptScheduleFps) promptScheduleFps.value = oldPromptSchedule; + if (alphaMaskMode) alphaMaskMode.value = oldPromptScheduleFps; + if (alphaDownsampleMode) alphaDownsampleMode.value = oldAlphaMaskMode; + if (alphaKeepThreshold) alphaKeepThreshold.value = oldAlphaDownsampleMode; + if (alphaMaskGrowPx) alphaMaskGrowPx.value = oldAlphaKeepThreshold; + if (transparentMaskValue) transparentMaskValue.value = oldAlphaMaskGrowPx; + if (opaqueMaskValue) opaqueMaskValue.value = oldTransparentMaskValue; + if (sendPixelMaskAttention) sendPixelMaskAttention.value = oldOpaqueMaskValue; + if (vaeAlphaRgbMode) vaeAlphaRgbMode.value = oldSendPixelMaskAttention; + if (vaeAlphaBleedPx) vaeAlphaBleedPx.value = oldVaeAlphaRgbMode; + } + + node.properties.etk_ltxv_disable_relay_widget_migrated = true; + return true; +} + +export function chainInputConnected(node) { + const input = getInput(node, CHAIN_INPUT_NAME); + return Boolean(input && input.link != null); +} + +export function applyWidgetInheritedState(widget, inherited) { + if (!widget) return; + widget.disabled = inherited; + widget.etkTimelineChainInherited = inherited; + if (!widget.options) widget.options = {}; + widget.options.disabled = inherited; + const opacity = inherited ? "0.45" : ""; + if (widget.element && widget.element.style) { + widget.element.style.opacity = opacity; + if ("readOnly" in widget.element) widget.element.readOnly = inherited; + if ("disabled" in widget.element) widget.element.disabled = false; + } + if (widget.inputEl) { + widget.inputEl.readOnly = inherited; + widget.inputEl.disabled = false; + if (widget.inputEl.style) widget.inputEl.style.opacity = opacity; + } +} + +export function syncChainInheritedWidgets(node) { + const inherited = chainInputConnected(node); + for (const widget of node.widgets || []) { + if (CHAIN_INHERITED_WIDGET_NAMES.has(widget.name)) { + applyWidgetInheritedState(widget, inherited); + } + } + node.etkTimelineChainInherited = inherited; +} + +export function syncKeyframePromptEditor(node, keyframe = null) { + const widget = keyframePromptEditorWidget(node); + if (!widget || !widget.inputEl) return; + const selected = keyframe || selectedKeyframe(node); + const title = widget.titleEl; + node.etkTimelineSyncingPromptEditor = true; + if (!selected) { + widget.inputEl.value = ""; + widget.inputEl.disabled = true; + widget.inputEl.placeholder = "Select or add a keyframe to edit its positive prompt"; + if (title) title.textContent = "Selected keyframe positive prompt"; + } else { + widget.inputEl.disabled = false; + widget.inputEl.value = normalizePrompt(selected.positivePrompt); + widget.inputEl.placeholder = "Positive prompt for selected keyframe"; + if (title) title.textContent = `Selected keyframe positive prompt - ${formatSlotSeconds(node, selected.slot)}`; + } + widget.value = widget.inputEl.value; + node.etkTimelineSyncingPromptEditor = false; +} + +export function commitKeyframePromptEditor(node) { + const widget = keyframePromptEditorWidget(node); + const selected = selectedKeyframe(node); + if (!widget || !widget.inputEl || !selected || node.etkTimelineSyncingPromptEditor) return; + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const index = keyframes.findIndex((keyframe) => keyframe.id === selected.id); + if (index < 0) return; + keyframes[index] = { ...keyframes[index], positivePrompt: widget.inputEl.value }; + node.properties.etk_ltxv_timeline_keyframes = keyframes; + node.properties.etk_ltxv_selected_keyframe = index; + node.properties.etk_ltxv_selected_keyframe_id = keyframes[index].id; + widget.value = widget.inputEl.value; + syncTimelineWidget(node); + node.setDirtyCanvas(true, true); +} + +export function makeKeyframePromptEditorElement(node) { + const container = document.createElement("div"); + container.style.cssText = ` + box-sizing: border-box; width: 100%; height: 100%; min-height: 0; + padding: 6px 10px 8px 10px; color: #dce7f5; + font-family: sans-serif; background: rgba(13, 17, 24, 0.72); + border: 1px solid #30394a; border-radius: 6px; + display: flex; flex-direction: column; gap: 4px; overflow: hidden; + `; + + const title = document.createElement("div"); + title.textContent = "Selected keyframe positive prompt"; + title.style.cssText = "font-size: 11px; font-weight: 700; color: #b9c7d9; flex: 0 0 auto;"; + container.appendChild(title); + + const textarea = document.createElement("textarea"); + textarea.rows = 3; + textarea.style.cssText = ` + box-sizing: border-box; width: 100%; height: auto; resize: none; + flex: 1 1 auto; min-height: 0; overflow: auto; + background: #111820; color: #f3f7ff; border: 1px solid #485668; + border-radius: 5px; padding: 6px 8px; font: 12px/1.25 sans-serif; + `; + container.appendChild(textarea); + + for (const eventName of ["pointerdown", "mousedown", "mouseup", "click", "dblclick", "wheel", "keydown", "keyup"]) { + container.addEventListener(eventName, stopDomEvent); + textarea.addEventListener(eventName, stopDomEvent); + } + textarea.addEventListener("input", () => commitKeyframePromptEditor(node)); + return { container, textarea, title }; +} + +export function ensureKeyframePromptEditorWidget(node) { + if (keyframePromptEditorWidget(node)) { + syncKeyframePromptEditor(node); + return; + } + + const { container, textarea, title } = makeKeyframePromptEditorElement(node); + const widgetOptions = { + serialize: false, + hideOnZoom: false, + selectOn: ["focus", "click"], + getMinHeight: () => KEYFRAME_PROMPT_EDITOR_HEIGHT, + getMaxHeight: () => KEYFRAME_PROMPT_EDITOR_MAX_HEIGHT, + getValue: () => textarea.value, + setValue: (value) => { + textarea.value = normalizePrompt(value); + }, + }; + const widget = + typeof node.addDOMWidget === "function" + ? node.addDOMWidget(KEYFRAME_POSITIVE_PROMPT_WIDGET, "customtext", container, widgetOptions) + : node.addCustomWidget + ? node.addCustomWidget({ + type: "customtext", + name: KEYFRAME_POSITIVE_PROMPT_WIDGET, + value: "", + serialize: false, + options: widgetOptions, + computeLayoutSize: () => ({ + minHeight: KEYFRAME_PROMPT_EDITOR_HEIGHT, + maxHeight: KEYFRAME_PROMPT_EDITOR_MAX_HEIGHT, + minWidth: 460, + }), + draw(ctx, owner, width, y, height) { + const keyframe = selectedKeyframe(owner); + this.last_y = y; + this.last_h = height; + this.computedHeight = height; + ctx.save(); + ctx.fillStyle = "rgba(13, 17, 24, 0.72)"; + ctx.fillRect(10, y + 4, Math.max(1, width - 20), Math.max(1, height - 8)); + ctx.strokeStyle = "#30394a"; + ctx.strokeRect(10, y + 4, Math.max(1, width - 20), Math.max(1, height - 8)); + ctx.fillStyle = "#b9c7d9"; + ctx.font = "bold 11px sans-serif"; + ctx.textAlign = "left"; + ctx.fillText( + keyframe ? `Selected keyframe prompt - ${formatSlotSeconds(owner, keyframe.slot)}` : "Selected keyframe positive prompt", + 20, + y + 22 + ); + ctx.fillStyle = keyframe ? "#f3f7ff" : "#7d8797"; + ctx.font = "12px sans-serif"; + const prompt = keyframe ? normalizePrompt(keyframe.positivePrompt) || "Click to edit prompt" : "Select or add a keyframe to edit its prompt"; + ctx.fillText(prompt.length > 120 ? `${prompt.slice(0, 117)}...` : prompt, 20, y + 44); + ctx.restore(); + }, + mouse(event, _pos, owner) { + const type = event && event.type ? event.type : ""; + if (!(type.includes("down") || type === "mousedown" || !type)) return false; + const keyframe = selectedKeyframe(owner); + if (!keyframe) { + setTimelineStatus(owner, "Select or add a keyframe before editing its prompt.", "error"); + return true; + } + editPromptModal(owner, keyframe.id, "positivePrompt", "Positive"); + return true; + }, + }) + : null; + if (!widget) return; + widget.serialize = false; + widget.element = container; + widget.inputEl = textarea; + widget.titleEl = title; + if (typeof widget.computeLayoutSize !== "function") { + widget.computeLayoutSize = () => ({ + minHeight: KEYFRAME_PROMPT_EDITOR_HEIGHT, + maxHeight: KEYFRAME_PROMPT_EDITOR_MAX_HEIGHT, + minWidth: 460, + }); + } + syncKeyframePromptEditor(node); +} + + +export function textLayerEditorWidget(node) { + return node.widgets ? node.widgets.find((widget) => widget && widget.name === TEXT_LAYER_EDITOR_WIDGET) : null; +} + +export function selectedTextLayerInfo(node) { + const keyframe = selectedKeyframe(node); + const layer = keyframe ? activeLayer(keyframe) : null; + if (!keyframe || !layer || !isTextLayer(layer)) return null; + return { keyframe, layer }; +} + +export function syncTextLayerEditor(node) { + const widget = textLayerEditorWidget(node); + if (!widget || !widget.textEl) return; + const selected = selectedTextLayerInfo(node); + const locked = layerEditingLocked(node); + const visible = Boolean(selected); + widget.hidden = !visible; + if (widget.element && widget.element.style) widget.element.style.display = visible ? "" : "none"; + if (visible && node.size && typeof node.computeSize === "function") { + applyTimelineCanvasSize(node); + } + node.etkTimelineSyncingTextLayerEditor = true; + const disabled = !selected || locked; + const controls = [ + widget.textEl, + widget.fontEl, + widget.sizeEl, + widget.boldEl, + widget.italicEl, + widget.alignEl, + widget.colorEl, + widget.outlineColorEl, + widget.outlineWidthEl, + widget.shadowColorEl, + widget.shadowBlurEl, + widget.shadowOffsetXEl, + widget.shadowOffsetYEl, + ].filter(Boolean); + for (const control of controls) control.disabled = disabled; + widget.textEl.placeholder = selected ? "Transparent text for the active layer" : "Select or add a text layer to edit text"; + if (widget.titleEl) { + widget.titleEl.textContent = selected + ? `Selected text layer - ${formatSlotSeconds(node, selected.keyframe.slot)}${locked ? " (locked)" : ""}` + : "Selected text layer"; + } + if (selected) { + const fontFamily = normalizeTextLayerFontFamily(selected.layer.fontFamily, DEFAULT_TEXT_LAYER.fontFamily); + ensureTextLayerFontOption(widget.fontEl, fontFamily); + widget.textEl.value = normalizeTextLayerString(selected.layer.text, DEFAULT_TEXT_LAYER.text); + widget.fontEl.value = fontFamily; + widget.sizeEl.value = String(Math.round(normalizeTextLayerFontSize(selected.layer.fontSize, DEFAULT_TEXT_LAYER.fontSize))); + widget.boldEl.checked = normalizeTextLayerBold(selected.layer.bold, false); + widget.italicEl.checked = normalizeTextLayerItalic(selected.layer.italic, false); + widget.alignEl.value = normalizeTextLayerAlign(selected.layer.align, DEFAULT_TEXT_LAYER.align); + widget.colorEl.value = normalizeTextLayerColor(selected.layer.color, DEFAULT_TEXT_LAYER.color).slice(0, 7); + widget.outlineColorEl.value = normalizeTextLayerColor(selected.layer.outlineColor, DEFAULT_TEXT_LAYER.outlineColor).slice(0, 7); + widget.outlineWidthEl.value = String(normalizeTextLayerNumber(selected.layer.outlineWidth, DEFAULT_TEXT_LAYER.outlineWidth, 0, 64)); + widget.shadowColorEl.value = normalizeTextLayerColor(selected.layer.shadowColor, DEFAULT_TEXT_LAYER.shadowColor).slice(0, 7); + widget.shadowBlurEl.value = String(normalizeTextLayerNumber(selected.layer.shadowBlur, DEFAULT_TEXT_LAYER.shadowBlur, 0, 128)); + widget.shadowOffsetXEl.value = String(normalizeTextLayerNumber(selected.layer.shadowOffsetX, DEFAULT_TEXT_LAYER.shadowOffsetX, -512, 512)); + widget.shadowOffsetYEl.value = String(normalizeTextLayerNumber(selected.layer.shadowOffsetY, DEFAULT_TEXT_LAYER.shadowOffsetY, -512, 512)); + } else { + ensureTextLayerFontOption(widget.fontEl, DEFAULT_TEXT_LAYER.fontFamily); + widget.textEl.value = ""; + widget.fontEl.value = DEFAULT_TEXT_LAYER.fontFamily; + widget.sizeEl.value = String(DEFAULT_TEXT_LAYER.fontSize); + widget.boldEl.checked = false; + widget.italicEl.checked = false; + widget.alignEl.value = DEFAULT_TEXT_LAYER.align; + widget.colorEl.value = DEFAULT_TEXT_LAYER.color; + widget.outlineColorEl.value = DEFAULT_TEXT_LAYER.outlineColor; + widget.outlineWidthEl.value = String(DEFAULT_TEXT_LAYER.outlineWidth); + widget.shadowColorEl.value = DEFAULT_TEXT_LAYER.shadowColor; + widget.shadowBlurEl.value = String(DEFAULT_TEXT_LAYER.shadowBlur); + widget.shadowOffsetXEl.value = String(DEFAULT_TEXT_LAYER.shadowOffsetX); + widget.shadowOffsetYEl.value = String(DEFAULT_TEXT_LAYER.shadowOffsetY); + } + widget.value = widget.textEl.value; + node.etkTimelineSyncingTextLayerEditor = false; +} + +export function commitTextLayerEditor(node) { + const widget = textLayerEditorWidget(node); + const selected = selectedTextLayerInfo(node); + if (!widget || !widget.textEl || !selected || node.etkTimelineSyncingTextLayerEditor) return; + if (rejectLayerEditIfLocked(node, "Text layer edit")) { + syncTextLayerEditor(node); + return; + } + updateActiveLayer(node, selected.keyframe.id, (current) => ({ + ...current, + type: "text", + image: null, + empty: false, + text: normalizeTextLayerString(widget.textEl.value, DEFAULT_TEXT_LAYER.text), + fontFamily: normalizeTextLayerFontFamily(widget.fontEl.value, DEFAULT_TEXT_LAYER.fontFamily), + fontSize: normalizeTextLayerFontSize(widget.sizeEl.value, DEFAULT_TEXT_LAYER.fontSize), + bold: Boolean(widget.boldEl.checked), + italic: Boolean(widget.italicEl.checked), + align: normalizeTextLayerAlign(widget.alignEl.value, DEFAULT_TEXT_LAYER.align), + color: normalizeTextLayerColor(widget.colorEl.value, DEFAULT_TEXT_LAYER.color), + outlineColor: normalizeTextLayerColor(widget.outlineColorEl.value, DEFAULT_TEXT_LAYER.outlineColor), + outlineWidth: normalizeTextLayerNumber(widget.outlineWidthEl.value, DEFAULT_TEXT_LAYER.outlineWidth, 0, 64), + shadowColor: normalizeTextLayerColor(widget.shadowColorEl.value, DEFAULT_TEXT_LAYER.shadowColor), + shadowBlur: normalizeTextLayerNumber(widget.shadowBlurEl.value, DEFAULT_TEXT_LAYER.shadowBlur, 0, 128), + shadowOffsetX: normalizeTextLayerNumber(widget.shadowOffsetXEl.value, DEFAULT_TEXT_LAYER.shadowOffsetX, -512, 512), + shadowOffsetY: normalizeTextLayerNumber(widget.shadowOffsetYEl.value, DEFAULT_TEXT_LAYER.shadowOffsetY, -512, 512), + })); +} + +export function makeTextLayerEditorElement(node) { + const container = document.createElement("div"); + container.style.cssText = ` + box-sizing: border-box; width: 100%; height: 100%; min-height: 0; + padding: 6px 10px 8px 10px; color: #dce7f5; + font-family: sans-serif; background: rgba(13, 17, 24, 0.72); + border: 1px solid #30394a; border-radius: 6px; + display: flex; flex-direction: column; gap: 5px; overflow: hidden; + `; + + const title = document.createElement("div"); + title.textContent = "Selected text layer"; + title.style.cssText = "font-size: 11px; font-weight: 700; color: #b9c7d9; flex: 0 0 auto;"; + container.appendChild(title); + + const textarea = document.createElement("textarea"); + textarea.rows = 2; + textarea.style.cssText = ` + box-sizing: border-box; width: 100%; resize: none; + flex: 1 1 auto; min-height: 30px; overflow: auto; + background: #111820; color: #f3f7ff; border: 1px solid #485668; + border-radius: 5px; padding: 5px 7px; font: 12px/1.25 sans-serif; + `; + container.appendChild(textarea); + + const makeRow = () => { + const row = document.createElement("div"); + row.style.cssText = "display: flex; gap: 6px; align-items: center; flex: 0 0 auto; min-height: 24px;"; + container.appendChild(row); + return row; + }; + const makeLabel = (text) => { + const label = document.createElement("label"); + label.textContent = text; + label.style.cssText = "display: flex; align-items: center; gap: 3px; color: #dce7f5; font: bold 10px sans-serif; white-space: nowrap;"; + return label; + }; + const inputStyle = "box-sizing: border-box; background: #111820; color: #f3f7ff; border: 1px solid #485668; border-radius: 4px; padding: 3px 5px; font: 11px sans-serif;"; + const colorStyle = "width: 32px; height: 22px; padding: 0; border: 1px solid #485668; border-radius: 4px; background: #111820;"; + const numberStyle = `${inputStyle} width: 48px;`; + + const row = makeRow(); + const font = document.createElement("select"); + font.style.cssText = `${inputStyle} flex: 1 1 auto; min-width: 102px;`; + for (const [value, label] of TEXT_LAYER_FONT_OPTIONS) { + const option = document.createElement("option"); + option.value = value; + option.textContent = label; + font.appendChild(option); + } + row.appendChild(font); + + const size = document.createElement("input"); + size.type = "number"; + size.min = "1"; + size.max = "512"; + size.step = "1"; + size.title = "Font size"; + size.style.cssText = numberStyle; + row.appendChild(size); + + const color = document.createElement("input"); + color.type = "color"; + color.title = "Text color"; + color.style.cssText = colorStyle; + row.appendChild(color); + + const boldLabel = makeLabel("B"); + const bold = document.createElement("input"); + bold.type = "checkbox"; + boldLabel.appendChild(bold); + row.appendChild(boldLabel); + + const italicLabel = makeLabel("I"); + const italic = document.createElement("input"); + italic.type = "checkbox"; + italicLabel.appendChild(italic); + row.appendChild(italicLabel); + + const align = document.createElement("select"); + align.title = "Text alignment"; + align.style.cssText = `${inputStyle} width: 68px;`; + for (const value of ["left", "center", "right"]) { + const option = document.createElement("option"); + option.value = value; + option.textContent = value; + align.appendChild(option); + } + row.appendChild(align); + + const styleRow = makeRow(); + const outlineLabel = makeLabel("Outline"); + const outlineColor = document.createElement("input"); + outlineColor.type = "color"; + outlineColor.title = "Outline color"; + outlineColor.style.cssText = colorStyle; + outlineLabel.appendChild(outlineColor); + const outlineWidth = document.createElement("input"); + outlineWidth.type = "number"; + outlineWidth.min = "0"; + outlineWidth.max = "64"; + outlineWidth.step = "1"; + outlineWidth.title = "Outline width"; + outlineWidth.style.cssText = numberStyle; + outlineLabel.appendChild(outlineWidth); + styleRow.appendChild(outlineLabel); + + const shadowLabel = makeLabel("Shadow"); + const shadowColor = document.createElement("input"); + shadowColor.type = "color"; + shadowColor.title = "Shadow color"; + shadowColor.style.cssText = colorStyle; + shadowLabel.appendChild(shadowColor); + const shadowBlur = document.createElement("input"); + shadowBlur.type = "number"; + shadowBlur.min = "0"; + shadowBlur.max = "128"; + shadowBlur.step = "1"; + shadowBlur.title = "Shadow blur"; + shadowBlur.style.cssText = numberStyle; + shadowLabel.appendChild(shadowBlur); + const shadowOffsetX = document.createElement("input"); + shadowOffsetX.type = "number"; + shadowOffsetX.min = "-512"; + shadowOffsetX.max = "512"; + shadowOffsetX.step = "1"; + shadowOffsetX.title = "Shadow X"; + shadowOffsetX.style.cssText = numberStyle; + shadowLabel.appendChild(shadowOffsetX); + const shadowOffsetY = document.createElement("input"); + shadowOffsetY.type = "number"; + shadowOffsetY.min = "-512"; + shadowOffsetY.max = "512"; + shadowOffsetY.step = "1"; + shadowOffsetY.title = "Shadow Y"; + shadowOffsetY.style.cssText = numberStyle; + shadowLabel.appendChild(shadowOffsetY); + styleRow.appendChild(shadowLabel); + + const controls = [textarea, font, size, bold, italic, align, color, outlineColor, outlineWidth, shadowColor, shadowBlur, shadowOffsetX, shadowOffsetY]; + for (const eventName of ["pointerdown", "mousedown", "mouseup", "click", "dblclick", "wheel", "keydown", "keyup"]) { + container.addEventListener(eventName, stopDomEvent); + for (const element of controls) element.addEventListener(eventName, stopDomEvent); + } + for (const element of controls) { + element.addEventListener("input", () => commitTextLayerEditor(node)); + element.addEventListener("change", () => commitTextLayerEditor(node)); + } + return { container, textarea, font, size, bold, italic, align, color, outlineColor, outlineWidth, shadowColor, shadowBlur, shadowOffsetX, shadowOffsetY, title }; +} + +export function ensureTextLayerEditorWidget(node) { + if (textLayerEditorWidget(node)) { + syncTextLayerEditor(node); + return; + } + + const { container, textarea, font, size, bold, italic, align, color, outlineColor, outlineWidth, shadowColor, shadowBlur, shadowOffsetX, shadowOffsetY, title } = makeTextLayerEditorElement(node); + const widgetOptions = { + serialize: false, + hideOnZoom: false, + selectOn: ["focus", "click"], + getMinHeight: () => TEXT_LAYER_EDITOR_HEIGHT, + getMaxHeight: () => TEXT_LAYER_EDITOR_MAX_HEIGHT, + getValue: () => textarea.value, + setValue: (value) => { + textarea.value = normalizeTextLayerString(value); + }, + }; + const widget = + typeof node.addDOMWidget === "function" + ? node.addDOMWidget(TEXT_LAYER_EDITOR_WIDGET, "customtext", container, widgetOptions) + : node.addCustomWidget + ? node.addCustomWidget({ + type: "customtext", + name: TEXT_LAYER_EDITOR_WIDGET, + value: "", + serialize: false, + options: widgetOptions, + computeLayoutSize: () => ({ + minHeight: TEXT_LAYER_EDITOR_HEIGHT, + maxHeight: TEXT_LAYER_EDITOR_MAX_HEIGHT, + minWidth: 460, + }), + draw(ctx, owner, width, y, height) { + const selected = selectedTextLayerInfo(owner); + this.last_y = y; + this.last_h = height; + this.computedHeight = height; + ctx.save(); + ctx.fillStyle = "rgba(13, 17, 24, 0.72)"; + ctx.fillRect(10, y + 4, Math.max(1, width - 20), Math.max(1, height - 8)); + ctx.strokeStyle = "#30394a"; + ctx.strokeRect(10, y + 4, Math.max(1, width - 20), Math.max(1, height - 8)); + ctx.fillStyle = "#b9c7d9"; + ctx.font = "bold 11px sans-serif"; + ctx.textAlign = "left"; + ctx.fillText("Selected text layer", 20, y + 22); + ctx.fillStyle = selected ? "#f3f7ff" : "#7d8797"; + ctx.font = "12px sans-serif"; + const text = selected ? normalizeTextLayerString(selected.layer.text, DEFAULT_TEXT_LAYER.text) : "Select or add a text layer to edit text"; + ctx.fillText(text.length > 120 ? `${text.slice(0, 117)}...` : text, 20, y + 44); + ctx.restore(); + }, + }) + : null; + if (!widget) return; + widget.serialize = false; + widget.element = container; + widget.textEl = textarea; + widget.fontEl = font; + widget.sizeEl = size; + widget.boldEl = bold; + widget.italicEl = italic; + widget.alignEl = align; + widget.colorEl = color; + widget.outlineColorEl = outlineColor; + widget.outlineWidthEl = outlineWidth; + widget.shadowColorEl = shadowColor; + widget.shadowBlurEl = shadowBlur; + widget.shadowOffsetXEl = shadowOffsetX; + widget.shadowOffsetYEl = shadowOffsetY; + widget.titleEl = title; + if (typeof widget.computeLayoutSize !== "function") { + widget.computeLayoutSize = () => ({ + minHeight: TEXT_LAYER_EDITOR_HEIGHT, + maxHeight: TEXT_LAYER_EDITOR_MAX_HEIGHT, + minWidth: 460, + }); + } + syncTextLayerEditor(node); +} diff --git a/web/ltxv_timeline/extension.js b/web/ltxv_timeline/extension.js new file mode 100644 index 0000000..161513f --- /dev/null +++ b/web/ltxv_timeline/extension.js @@ -0,0 +1,568 @@ +// ComfyUI extension registration and node lifecycle wiring. +import { app } from "../../../../scripts/app.js"; +import { + CANVAS_SIZE_PROPERTY, + CONTROL_TAB_PROPERTY, + LAYER_LOCK_PROPERTY, + OUTPUT_SETTINGS_PROPERTY, + UI_INSTANCE_PROPERTY, + UPLOAD_MODE_PROPERTY, +} from "./constants.js"; +import { + applyBackendTimelineSettings, + installBackendTimelineSettingsListener, + isTargetNode, + normalizeTimelineSettings, + payloadsMatchNode, + setTimelineStatus, + uiPayloadValue, +} from "./runtime.js"; +import { + activeLayer, + controlsTab, + deleteActiveLayer, + ensureSelectedKeyframe, + layerEditingLocked, + normalizeKeyframes, + normalizePan, + preloadImage, + preserveExecutedFilterFields, + refreshTimeline, + rejectLayerEditIfLocked, + selectedKeyframe, + selectedKeyframeIndex, + serializableTimelinePayload, + setActiveLayerId, + setControlsTab, + setSelectedKeyframe, + syncTimelineWidget, + timelineReloadSource, + toggleLayerEditingLock, + toggleUploadMode, + updateActiveLayer, + uploadMode, +} from "./model.js"; +import { + fixPromptWidgetHeights, + migrateDisableRelayWidgetOrder, + syncChainInheritedWidgets, + syncKeyframePromptEditor, + syncTextLayerEditor, +} from "./editors.js"; +import { + configuredCanvasSize, + rememberTimelineCanvasSize, + scheduleTimelineCanvasSizeRestore, + setupNode, +} from "./node_setup.js"; +import { + addKeyframeAt, + bounds, + deleteKeyframeAtIndex, + duplicateKeyframeAtIndex, + findKeyframeAt, + previewImageBox, + slotConflict, + slotToSeconds, + updateTimelineSliderDrag, + widgetOutputSettings, + xToSlot, +} from "./timeline_ops.js"; +import { + controlTabAt, + layerChipAt, + previewPlacement, + previewSourceForLayer, + refreshButtonBounds, +} from "./rendering.js"; +import { + addTextLayer, + deleteLayerButtonBounds, + imageButtonBounds, + installKeyboardShortcuts, + installPointerRelease, + installWheelCapture, + keyframeStrengthSliderAt, + layerFilterSliderAt, + lockLayerButtonBounds, + modeButtonBounds, + releaseDrag, + textLayerButtonBounds, + uploadModeButtonBounds, + zoomPreviewAt, +} from "./interaction.js"; +import { installClipboardPasteCapture, installFileDropCapture } from "./media.js"; +export function selectedKeyframeIndexForMenu(node) { + setupNode(node); + return selectedKeyframeIndex(node, normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node)); +} + +export function timelineNodeMenuItems(node) { + if (!isTargetNode(node)) return []; + return [ + null, + { + content: "ETK Timeline: Refresh previews", + callback: () => { + setupNode(node); + refreshTimeline(node); + }, + }, + { + content: `ETK Timeline: Upload mode: ${uploadMode(node) === "layer" ? "add layer" : "replace"}`, + callback: () => { + setupNode(node); + toggleUploadMode(node); + }, + }, + { + content: "ETK Timeline: Upload image to selected keyframe...", + callback: () => { + setupNode(node); + const keyframe = ensureSelectedKeyframe(node); + if (!keyframe) return; + if (node.etkTimelineFileInput) node.etkTimelineFileInput.click(); + }, + }, + { + content: "ETK Timeline: Duplicate selected keyframe", + callback: () => { + const index = selectedKeyframeIndexForMenu(node); + if (index < 0 || !duplicateKeyframeAtIndex(node, index)) { + setTimelineStatus(node, "Select a keyframe to duplicate.", "error"); + } + }, + }, + { + content: "ETK Timeline: Delete selected keyframe", + callback: () => { + const index = selectedKeyframeIndexForMenu(node); + if (index < 0 || !deleteKeyframeAtIndex(node, index)) { + setTimelineStatus(node, "Select a keyframe to delete.", "error"); + } + }, + }, + ]; +} + +app.registerExtension({ + name: "ETK.LTXVTimelineImageEditor", + getNodeMenuItems(node) { + return timelineNodeMenuItems(node); + }, + beforeRegisterNodeDef(nodeType, nodeData) { + if (!isTargetNode(nodeData.name)) return; + if (nodeType.prototype.etkTimelinePatched) return; + nodeType.prototype.etkTimelinePatched = true; + installBackendTimelineSettingsListener(); + installWheelCapture(); + installPointerRelease(); + installKeyboardShortcuts(); + installFileDropCapture(); + installClipboardPasteCapture(); + + const originalGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function (canvas, options) { + const extra = originalGetExtraMenuOptions ? originalGetExtraMenuOptions.apply(this, arguments) : undefined; + const timelineItems = timelineNodeMenuItems(this); + if (Array.isArray(extra)) return extra.concat(timelineItems); + if (Array.isArray(options)) options.push(...timelineItems); + return extra; + }; + + const originalOnNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + if (originalOnNodeCreated) originalOnNodeCreated.apply(this, arguments); + setupNode(this); + scheduleTimelineCanvasSizeRestore(this); + }; + + const originalOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (config) { + if (originalOnConfigure) originalOnConfigure.apply(this, arguments); + if (!this.properties) this.properties = {}; + const migratedWidgetOrder = migrateDisableRelayWidgetOrder(this); + this.properties.etk_ltxv_timeline_keyframes = normalizeKeyframes(timelineReloadSource(this, migratedWidgetOrder), this); + setupNode(this); + scheduleTimelineCanvasSizeRestore(this, configuredCanvasSize(config)); + selectedKeyframeIndex(this, this.properties.etk_ltxv_timeline_keyframes); + }; + + const originalOnSerialize = nodeType.prototype.onSerialize; + nodeType.prototype.onSerialize = function (serialized) { + if (originalOnSerialize) originalOnSerialize.apply(this, arguments); + if (!serialized) return; + setupNode(this); + const keyframes = normalizeKeyframes(this.properties.etk_ltxv_timeline_keyframes, this); + this.properties.etk_ltxv_timeline_keyframes = keyframes; + syncTimelineWidget(this); + if (!serialized.properties) serialized.properties = {}; + serialized.properties.etk_ltxv_timeline_keyframes = serializableTimelinePayload(this, keyframes); + serialized.properties.etk_ltxv_selected_keyframe = this.properties.etk_ltxv_selected_keyframe; + serialized.properties.etk_ltxv_selected_keyframe_id = this.properties.etk_ltxv_selected_keyframe_id; + serialized.properties[UPLOAD_MODE_PROPERTY] = uploadMode(this); + serialized.properties[LAYER_LOCK_PROPERTY] = layerEditingLocked(this); + serialized.properties[CONTROL_TAB_PROPERTY] = controlsTab(this); + const outputSettings = + normalizeTimelineSettings(this.etkTimelineBackendSettings || this.properties[OUTPUT_SETTINGS_PROPERTY]) || + widgetOutputSettings(this); + if (outputSettings) { + serialized.properties[OUTPUT_SETTINGS_PROPERTY] = outputSettings; + } + const canvasSize = rememberTimelineCanvasSize(this); + if (canvasSize) { + serialized.size = [...canvasSize]; + serialized.properties[CANVAS_SIZE_PROPERTY] = [...canvasSize]; + } + delete serialized.properties[UI_INSTANCE_PROPERTY]; + }; + + const originalOnExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function (message) { + if (originalOnExecuted) originalOnExecuted.apply(this, arguments); + const payload = uiPayloadValue(message, "etk_ltxv_timeline_keyframes"); + const settingsPayload = uiPayloadValue(message, "etk_ltxv_timeline_settings"); + if (!payload && !settingsPayload) return; + if (!payloadsMatchNode(this, [payload, settingsPayload])) return; + setupNode(this); + applyBackendTimelineSettings(this, settingsPayload); + if (payload) { + const keyframes = preserveExecutedFilterFields(this, payload); + this.properties.etk_ltxv_timeline_keyframes = keyframes; + const selectedIndex = selectedKeyframeIndex(this, keyframes); + if (selectedIndex < 0 && keyframes.length) { + setSelectedKeyframe(this, 0, keyframes); + } else { + syncKeyframePromptEditor(this, selectedIndex >= 0 ? keyframes[selectedIndex] : null); + syncTextLayerEditor(this); + } + for (const keyframe of keyframes) preloadImage(this, keyframe); + } + syncTimelineWidget(this); + this.setDirtyCanvas(true, true); + if (this.graph && typeof this.graph.setDirtyCanvas === "function") { + this.graph.setDirtyCanvas(true, true); + } + }; + + const originalOnRemoved = nodeType.prototype.onRemoved; + nodeType.prototype.onRemoved = function () { + releaseDrag(this); + if (this.etkTimelineFileInput) { + this.etkTimelineFileInput.remove(); + this.etkTimelineFileInput = null; + } + if (originalOnRemoved) originalOnRemoved.apply(this, arguments); + }; + + const originalComputeSize = nodeType.prototype.computeSize; + nodeType.prototype.computeSize = function () { + fixPromptWidgetHeights(this); + const size = originalComputeSize ? originalComputeSize.apply(this, arguments) : [360, 260]; + size[0] = Math.max(size[0], 460); + return size; + }; + + const originalOnResize = nodeType.prototype.onResize; + nodeType.prototype.onResize = function (size) { + fixPromptWidgetHeights(this); + syncChainInheritedWidgets(this); + if (originalOnResize) originalOnResize.apply(this, arguments); + if (!size) return; + size[0] = Math.max(size[0], 460); + rememberTimelineCanvasSize(this, size); + if (this.setDirtyCanvas) this.setDirtyCanvas(true, true); + }; + + const originalOnConnectionsChange = nodeType.prototype.onConnectionsChange; + nodeType.prototype.onConnectionsChange = function () { + if (originalOnConnectionsChange) originalOnConnectionsChange.apply(this, arguments); + setupNode(this); + syncChainInheritedWidgets(this); + if (this.setDirtyCanvas) this.setDirtyCanvas(true, true); + }; + + const handleTimelineDoubleClick = function () { + const pos = Array.from(arguments).find((arg) => Array.isArray(arg) && arg.length >= 2); + if (!pos) return false; + setupNode(this); + const { padX, width, timelineY, timelineH } = bounds(this); + const [x, y] = pos; + if (x < padX || x > padX + width || y < timelineY || y > timelineY + timelineH) return false; + if (findKeyframeAt(this, pos) >= 0) return false; + addKeyframeAt(this, x); + return true; + }; + + const originalMouseDblClick = nodeType.prototype.onMouseDblClick; + nodeType.prototype.onMouseDblClick = function (_event, pos) { + if (handleTimelineDoubleClick.apply(this, arguments)) return true; + return originalMouseDblClick ? originalMouseDblClick.apply(this, arguments) : false; + }; + + const originalDblClick = nodeType.prototype.onDblClick; + nodeType.prototype.onDblClick = function () { + if (handleTimelineDoubleClick.apply(this, arguments)) return true; + return originalDblClick ? originalDblClick.apply(this, arguments) : false; + }; + + const originalMouseDown = nodeType.prototype.onMouseDown; + nodeType.prototype.onMouseDown = function (event, pos) { + if (event && event.button != null && event.button !== 0) { + return originalMouseDown ? originalMouseDown.apply(this, arguments) : false; + } + setupNode(this); + const keyframes = normalizeKeyframes(this.properties.etk_ltxv_timeline_keyframes, this); + const hitIndex = findKeyframeAt(this, pos); + const { padX, width, timelineY, timelineH, controlsY, controlsH, previewY, previewH } = bounds(this); + const [x, y] = pos; + const inControls = x >= padX && x <= padX + width && y >= controlsY && y <= controlsY + controlsH; + const inPreview = x >= padX && x <= padX + width && y >= previewY && y <= previewY + previewH; + const refreshButton = refreshButtonBounds(this); + + if ( + x >= refreshButton.x && + x <= refreshButton.x + refreshButton.w && + y >= refreshButton.y && + y <= refreshButton.y + refreshButton.h + ) { + refreshTimeline(this); + return true; + } + + if (hitIndex >= 0) { + setSelectedKeyframe(this, hitIndex, keyframes); + this.etkTimelineDragging = true; + this.etkTimelineDraggingId = keyframes[hitIndex].id; + window.etkTimelineActiveDragNode = this; + this.setDirtyCanvas(true, true); + return true; + } + + if (x >= padX && x <= padX + width && y >= timelineY && y <= timelineY + timelineH) { + const now = performance.now(); + const isDoubleClick = + (event && event.detail && event.detail >= 2) || + (this.etkTimelineLastClick && now - this.etkTimelineLastClick < 350); + if (isDoubleClick) { + addKeyframeAt(this, x); + this.etkTimelineLastClick = 0; + } else { + this.etkTimelineLastClick = now; + this.setDirtyCanvas(true, true); + } + return true; + } + + if (inControls || inPreview) { + if (inControls) { + const tab = controlTabAt(this, x, y); + if (tab) { + setControlsTab(this, tab); + return true; + } + } + const selected = selectedKeyframe(this); + if (selected) { + const activeControlTab = controlsTab(this); + if (inControls && activeControlTab === "layers") { + const button = modeButtonBounds(this); + const imageButton = imageButtonBounds(this); + const textButton = textLayerButtonBounds(this); + const uploadButton = uploadModeButtonBounds(this); + const lockLayerButton = lockLayerButtonBounds(this); + const deleteLayerButton = deleteLayerButtonBounds(this); + if (x >= lockLayerButton.x && x <= lockLayerButton.x + lockLayerButton.w && y >= lockLayerButton.y && y <= lockLayerButton.y + lockLayerButton.h) { + toggleLayerEditingLock(this); + return true; + } + if (x >= button.x && x <= button.x + button.w && y >= button.y && y <= button.y + button.h) { + if (rejectLayerEditIfLocked(this, "Fit mode change")) return true; + const layer = activeLayer(selected); + if (layer) { + updateActiveLayer(this, selected.id, (current) => ({ + ...current, + fitMode: current.fitMode === "crop" ? "pad" : "crop", + })); + } else { + selected.fitMode = selected.fitMode === "crop" ? "pad" : "crop"; + const selectedIndex = selectedKeyframeIndex(this, keyframes); + if (selectedIndex < 0) return true; + keyframes[selectedIndex] = selected; + this.properties.etk_ltxv_timeline_keyframes = keyframes; + syncTimelineWidget(this); + this.setDirtyCanvas(true, true); + } + return true; + } + if (x >= imageButton.x && x <= imageButton.x + imageButton.w && y >= imageButton.y && y <= imageButton.y + imageButton.h) { + if (rejectLayerEditIfLocked(this, "Image upload")) return true; + this.etkTimelineFileInput.click(); + return true; + } + if (x >= textButton.x && x <= textButton.x + textButton.w && y >= textButton.y && y <= textButton.y + textButton.h) { + addTextLayer(this, selected.id); + return true; + } + if (x >= uploadButton.x && x <= uploadButton.x + uploadButton.w && y >= uploadButton.y && y <= uploadButton.y + uploadButton.h) { + if (rejectLayerEditIfLocked(this, "Upload mode change")) return true; + toggleUploadMode(this); + return true; + } + if ( + x >= deleteLayerButton.x && + x <= deleteLayerButton.x + deleteLayerButton.w && + y >= deleteLayerButton.y && + y <= deleteLayerButton.y + deleteLayerButton.h + ) { + deleteActiveLayer(this, selected.id); + return true; + } + const chip = layerChipAt(this, selected, x, y); + if (chip) { + setActiveLayerId(this, selected.id, chip.layerId); + return true; + } + } else if (inControls && activeControlTab === "filters") { + const layer = activeLayer(selected); + const filterSlider = layer ? layerFilterSliderAt(this, x, y) : null; + if (layer && filterSlider) { + this.etkSliderDragging = { family: "filter", keyframeId: selected.id, layerId: layer.id, kind: filterSlider.kind }; + window.etkTimelineActiveDragNode = this; + updateTimelineSliderDrag(this, pos); + return true; + } + } else if (inControls && activeControlTab === "strength") { + const strengthSlider = keyframeStrengthSliderAt(this, x, y); + if (strengthSlider) { + this.etkSliderDragging = { family: "strength", keyframeId: selected.id, kind: strengthSlider.kind }; + window.etkTimelineActiveDragNode = this; + updateTimelineSliderDrag(this, pos); + return true; + } + } + const layer = activeLayer(selected); + if (layer && inPreview) { + if (layerEditingLocked(this)) { + rejectLayerEditIfLocked(this, "Layer pan"); + return true; + } + const { boxX, boxY, boxW, boxH } = previewImageBox(this); + if (x >= boxX && x <= boxX + boxW && y >= boxY && y <= boxY + boxH) { + this.etkPreviewPanning = { + keyframeId: selected.id, + layerId: layer.id, + startX: x, + startY: y, + startPanX: normalizePan(layer.panX), + startPanY: normalizePan(layer.panY), + }; + window.etkTimelineActiveDragNode = this; + return true; + } + } else if (!layer && inPreview) { + if (rejectLayerEditIfLocked(this, "Image upload")) return true; + this.etkTimelineFileInput.click(); + } + return true; + } + } + + return originalMouseDown ? originalMouseDown.apply(this, arguments) : false; + }; + + const originalMouseMove = nodeType.prototype.onMouseMove; + nodeType.prototype.onMouseMove = function (event, pos) { + if (this.etkSliderDragging) { + if (event && event.buttons === 0) { + releaseDrag(this); + return true; + } + updateTimelineSliderDrag(this, pos); + return true; + } + if (this.etkPreviewPanning) { + if (event && event.buttons === 0) { + releaseDrag(this); + return true; + } + const keyframes = normalizeKeyframes(this.properties.etk_ltxv_timeline_keyframes, this); + const keyframe = keyframes.find((candidate) => candidate.id === this.etkPreviewPanning.keyframeId); + const layer = keyframe && (keyframe.layers || []).find((candidate) => candidate.id === this.etkPreviewPanning.layerId); + const image = layer ? previewSourceForLayer(this, layer) : null; + if (keyframe && layer && image && image.complete && image.naturalWidth > 0 && image.naturalHeight > 0) { + const { boxW, boxH } = previewImageBox(this); + const placement = previewPlacement( + image, + boxW, + boxH, + layer.fitMode, + layer.panX, + layer.panY, + layer.zoom + ); + const dx = pos[0] - this.etkPreviewPanning.startX; + const dy = pos[1] - this.etkPreviewPanning.startY; + const nextPanX = + placement.panRangeX > 0 + ? this.etkPreviewPanning.startPanX + dx / (placement.panRangeX / 2) + : 0; + const nextPanY = + placement.panRangeY > 0 + ? this.etkPreviewPanning.startPanY + dy / (placement.panRangeY / 2) + : 0; + updateActiveLayer(this, keyframe.id, (current) => ({ + ...current, + panX: normalizePan(nextPanX), + panY: normalizePan(nextPanY), + })); + } + return true; + } + if (this.etkTimelineDragging) { + if (event && event.buttons === 0) { + releaseDrag(this); + return true; + } + const keyframes = normalizeKeyframes(this.properties.etk_ltxv_timeline_keyframes, this); + const draggingId = this.etkTimelineDraggingId || this.properties.etk_ltxv_selected_keyframe_id; + const index = draggingId + ? keyframes.findIndex((keyframe) => keyframe.id === draggingId) + : selectedKeyframeIndex(this, keyframes); + if (index >= 0 && index < keyframes.length) { + const slot = xToSlot(this, pos[0]); + if (slotConflict(this, slot, index) < 0) { + const selected = keyframes[index]; + selected.slot = slot; + selected.time = slotToSeconds(this, slot); + keyframes.sort((a, b) => a.time - b.time || a.slot - b.slot); + this.properties.etk_ltxv_timeline_keyframes = keyframes; + setSelectedKeyframe(this, keyframes.findIndex((keyframe) => keyframe.id === selected.id), keyframes); + syncTimelineWidget(this); + } + this.setDirtyCanvas(true, true); + } + return true; + } + return originalMouseMove ? originalMouseMove.apply(this, arguments) : false; + }; + + const originalMouseWheel = nodeType.prototype.onMouseWheel; + nodeType.prototype.onMouseWheel = function () { + const pos = Array.from(arguments).find((arg) => Array.isArray(arg) && arg.length >= 2); + const event = Array.from(arguments).find((arg) => arg && typeof arg === "object" && !Array.isArray(arg)); + if (zoomPreviewAt(this, event, pos)) { + if (event && event.preventDefault) event.preventDefault(); + if (event && event.stopPropagation) event.stopPropagation(); + return true; + } + return originalMouseWheel ? originalMouseWheel.apply(this, arguments) : false; + }; + + const originalMouseUp = nodeType.prototype.onMouseUp; + nodeType.prototype.onMouseUp = function () { + if (releaseDrag(this)) return true; + return originalMouseUp ? originalMouseUp.apply(this, arguments) : false; + }; + }, +}); diff --git a/web/ltxv_timeline/interaction.js b/web/ltxv_timeline/interaction.js new file mode 100644 index 0000000..a6e7bdf --- /dev/null +++ b/web/ltxv_timeline/interaction.js @@ -0,0 +1,361 @@ +// Layer controls, pointer/keyboard interaction, zoom, and global input listeners. +import { app } from "../../../../scripts/app.js"; +import { + CTRL_KEYBOARD_PAN_IMAGE_PX, + CTRL_WHEEL_ZOOM_STEP, + DEFAULT_TEXT_LAYER, + KEYBOARD_PAN_STEP, + SHIFT_WHEEL_ZOOM_STEP, + WHEEL_ZOOM_STEP, +} from "./constants.js"; +import { + activeCanvasGraph, + addTimelineWindowListener, + graphNodes, + isTargetNode, + makeId, + setTimelineStatus, +} from "./runtime.js"; +import { + activeLayer, + activeLayerIndex, + ensureSelectedKeyframe, + isEmptyLayer, + mirrorBaseLayer, + normalizeKeyframes, + normalizeLayer, + normalizePan, + normalizeZoom, + rejectLayerEditIfLocked, + selectedKeyframe, + selectedKeyframeIndex, + setSelectedKeyframe, + syncTimelineWidget, + updateActiveLayer, + viewUrl, +} from "./model.js"; +import { syncTextLayerEditor } from "./editors.js"; +import { setupNode } from "./node_setup.js"; +import { + deleteKeyframeAtIndex, + duplicateKeyframeAtIndex, + findKeyframeAt, + formatSlotSeconds, + previewImageBox, +} from "./timeline_ops.js"; +import { controlContentBounds, previewPlacement } from "./rendering.js"; +import { nodeFromGraphMouse, stopKeyboardShortcutEvent } from "./media.js"; +export function modeButtonBounds(node, areaY = null, areaH = null) { + const content = controlContentBounds(node, areaY, areaH); + return { x: content.x + 192, y: content.y + 4, w: 68, h: 22 }; +} + +export function lockLayerButtonBounds(node, areaY = null, areaH = null) { + const content = controlContentBounds(node, areaY, areaH); + return { x: content.x + 268, y: content.y + 4, w: 56, h: 22 }; +} + +export function imageButtonBounds(node, areaY = null, areaH = null) { + const content = controlContentBounds(node, areaY, areaH); + return { x: content.x + 136, y: content.y + 4, w: 50, h: 22 }; +} + +export function textLayerButtonBounds(node, areaY = null, areaH = null) { + const content = controlContentBounds(node, areaY, areaH); + return { x: content.x + 332, y: content.y + 4, w: 50, h: 22 }; +} + +export function uploadModeButtonBounds(node, areaY = null, areaH = null) { + const content = controlContentBounds(node, areaY, areaH); + return { x: content.x + 50, y: content.y + 4, w: 78, h: 22 }; +} + +export function deleteLayerButtonBounds(node, areaY = null, areaH = null) { + const content = controlContentBounds(node, areaY, areaH); + return { x: content.x, y: content.y + 4, w: 42, h: 22 }; +} + +export function keyframeStrengthSliderBounds(node, kind, areaY = null, areaH = null) { + const content = controlContentBounds(node, areaY, areaH); + const sliderW = Math.min(520, Math.max(220, content.w)); + const x = content.x; + const y = content.y + (kind === "latent" ? 0 : 25); + return { + kind, + label: kind === "latent" ? "latent strength" : "prompt strength", + min: 0, + max: kind === "latent" ? 1 : 4, + color: kind === "latent" ? "#42c06e" : "#7d6ccf", + x, + y, + w: sliderW, + h: 19, + trackX: x + 104, + trackY: y + 8, + trackW: Math.max(50, sliderW - 116), + trackH: 4, + }; +} + +export function keyframeStrengthSliderAt(node, x, y) { + for (const kind of ["latent", "prompt"]) { + const slider = keyframeStrengthSliderBounds(node, kind); + if (x >= slider.x && x <= slider.x + slider.w && y >= slider.y && y <= slider.y + slider.h) { + return slider; + } + } + return null; +} + +export function layerFilterSliderBounds(node, kind, areaY = null, areaH = null) { + const content = controlContentBounds(node, areaY, areaH); + const sliderW = Math.min(520, Math.max(220, content.w)); + const x = content.x; + const y = content.y + (kind === "brightness" ? 0 : 25); + return { + kind, + label: kind, + min: 0, + max: 2, + color: kind === "brightness" ? "#f0c45c" : "#5fb4f0", + x, + y, + w: sliderW, + h: 19, + trackX: x + 86, + trackY: y + 8, + trackW: Math.max(50, sliderW - 98), + trackH: 4, + }; +} + +export function layerFilterSliderAt(node, x, y) { + for (const kind of ["brightness", "contrast"]) { + const slider = layerFilterSliderBounds(node, kind); + if (x >= slider.x && x <= slider.x + slider.w && y >= slider.y && y <= slider.y + slider.h) { + return slider; + } + } + return null; +} + +export function addTextLayer(node, keyframeId = null) { + if (rejectLayerEditIfLocked(node, "Text layer add")) return false; + const keyframe = keyframeId == null ? ensureSelectedKeyframe(node) : selectedKeyframe(node); + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const index = keyframeId == null + ? selectedKeyframeIndex(node, keyframes) + : keyframes.findIndex((candidate) => candidate.id === keyframeId); + if (!keyframe || index < 0) return false; + const target = keyframes[index]; + const layers = Array.isArray(target.layers) ? target.layers.slice() : []; + const activeIndex = activeLayerIndex(target); + const textLayer = normalizeLayer({ + id: makeId(), + type: "text", + text: DEFAULT_TEXT_LAYER.text, + fontFamily: DEFAULT_TEXT_LAYER.fontFamily, + fontSize: DEFAULT_TEXT_LAYER.fontSize, + bold: DEFAULT_TEXT_LAYER.bold, + italic: DEFAULT_TEXT_LAYER.italic, + align: DEFAULT_TEXT_LAYER.align, + color: DEFAULT_TEXT_LAYER.color, + outlineColor: DEFAULT_TEXT_LAYER.outlineColor, + outlineWidth: DEFAULT_TEXT_LAYER.outlineWidth, + shadowColor: DEFAULT_TEXT_LAYER.shadowColor, + shadowBlur: DEFAULT_TEXT_LAYER.shadowBlur, + shadowOffsetX: DEFAULT_TEXT_LAYER.shadowOffsetX, + shadowOffsetY: DEFAULT_TEXT_LAYER.shadowOffsetY, + fitMode: "pad", + panX: 0, + panY: 0, + zoom: 1, + }); + if (activeIndex >= 0 && isEmptyLayer(layers[activeIndex])) { + textLayer.id = layers[activeIndex].id || textLayer.id; + layers[activeIndex] = textLayer; + } else { + layers.push(textLayer); + } + target.layers = layers; + target.selectedLayerId = textLayer.id; + mirrorBaseLayer(target); + keyframes[index] = target; + node.properties.etk_ltxv_timeline_keyframes = keyframes; + setSelectedKeyframe(node, index, keyframes); + syncTimelineWidget(node); + syncTextLayerEditor(node); + setTimelineStatus(node, `Added text layer at ${formatSlotSeconds(node, target.slot)}.`, "success"); + node.setDirtyCanvas(true, true); + return true; +} + +export function releaseDrag(node) { + if (!node || (!node.etkTimelineDragging && !node.etkPreviewPanning && !node.etkSliderDragging)) return false; + node.etkTimelineDragging = false; + node.etkTimelineDraggingId = null; + node.etkPreviewPanning = null; + node.etkSliderDragging = null; + if (window.etkTimelineActiveDragNode === node) window.etkTimelineActiveDragNode = null; + syncTimelineWidget(node); + node.setDirtyCanvas(true, true); + return true; +} + +export function wheelZoomStep(event) { + if (event && (event.ctrlKey || event.metaKey)) return CTRL_WHEEL_ZOOM_STEP; + if (event && event.shiftKey) return SHIFT_WHEEL_ZOOM_STEP; + return WHEEL_ZOOM_STEP; +} + +export function zoomPreviewAt(node, event, pos) { + setupNode(node); + const selected = selectedKeyframe(node); + const layer = selected ? activeLayer(selected) : null; + if (!selected || !layer || !pos) return false; + const { boxX, boxY, boxW, boxH } = previewImageBox(node); + const [x, y] = pos; + if (x < boxX || x > boxX + boxW || y < boxY || y > boxY + boxH) return false; + if (rejectLayerEditIfLocked(node, "Layer zoom")) return true; + const delta = event && Number.isFinite(event.deltaY) ? event.deltaY : 0; + const step = wheelZoomStep(event); + const factor = delta < 0 ? 1 + step : 1 - step; + updateActiveLayer(node, selected.id, (current) => ({ + ...current, + zoom: normalizeZoom(normalizeZoom(current.zoom) * factor), + })); + return true; +} + +export function isNodeSelected(node) { + const canvas = app.canvas; + if (!node || !canvas) return false; + if (canvas.selected_node === node) return true; + const selected = canvas.selected_nodes || canvas.selectedNodes; + if (Array.isArray(selected)) return selected.includes(node); + if (selected && typeof selected === "object") { + if (node.id != null && (selected[node.id] === node || selected[node.id] === true)) return true; + if (Object.values(selected).includes(node)) return true; + } + return Boolean(node.selected || node.is_selected || (node.flags && node.flags.selected)); +} + +export function panPreviewWithKeyboard(node, event, pos) { + setupNode(node); + const key = String(event && event.key ? event.key : ""); + const directions = { + ArrowLeft: [-1, 0], + ArrowRight: [1, 0], + ArrowUp: [0, -1], + ArrowDown: [0, 1], + }; + const direction = directions[key]; + if (!direction) return false; + const selected = selectedKeyframe(node); + const layer = selected ? activeLayer(selected) : null; + if (!selected || !layer || isEmptyLayer(layer) || !pos) return false; + const { boxX, boxY, boxW, boxH } = previewImageBox(node); + const [x, y] = pos; + if (x < boxX || x > boxX + boxW || y < boxY || y > boxY + boxH) return false; + if (rejectLayerEditIfLocked(node, "Layer pan")) return true; + + let deltaX = direction[0] * KEYBOARD_PAN_STEP; + let deltaY = direction[1] * KEYBOARD_PAN_STEP; + if (event && event.ctrlKey) { + const url = viewUrl(layer.image); + const image = url && node.etkTimelineImageCache ? node.etkTimelineImageCache.get(url) : null; + if (!image || !image.complete || image.naturalWidth <= 0 || image.naturalHeight <= 0) return false; + const placement = previewPlacement( + image, + boxW, + boxH, + layer.fitMode, + layer.panX, + layer.panY, + layer.zoom + ); + const sourcePixelW = placement.drawW / image.naturalWidth; + const sourcePixelH = placement.drawH / image.naturalHeight; + deltaX = direction[0] ? direction[0] * CTRL_KEYBOARD_PAN_IMAGE_PX * sourcePixelW / (placement.panRangeX / 2) : 0; + deltaY = direction[1] ? direction[1] * CTRL_KEYBOARD_PAN_IMAGE_PX * sourcePixelH / (placement.panRangeY / 2) : 0; + } + + updateActiveLayer(node, selected.id, (current) => ({ + ...current, + panX: normalizePan(current.panX) + deltaX, + panY: normalizePan(current.panY) + deltaY, + })); + return true; +} + +export function installWheelCapture() { + addTimelineWindowListener( + "wheelCapture.wheel", + "wheel", + (event) => { + const canvas = app.canvas; + const graphMouse = canvas ? canvas.graph_mouse : null; + const node = nodeFromGraphMouse(graphMouse); + if (!node) return; + const localPos = [graphMouse[0] - node.pos[0], graphMouse[1] - node.pos[1]]; + if (!zoomPreviewAt(node, event, localPos)) return; + event.preventDefault(); + event.stopPropagation(); + if (event.stopImmediatePropagation) event.stopImmediatePropagation(); + }, + { capture: true, passive: false } + ); +} + +export function installPointerRelease() { + const releaseActiveDrag = () => { + releaseDrag(window.etkTimelineActiveDragNode); + }; + addTimelineWindowListener("pointerRelease.mouseup", "mouseup", releaseActiveDrag, true); + addTimelineWindowListener("pointerRelease.pointerup", "pointerup", releaseActiveDrag, true); + addTimelineWindowListener("pointerRelease.pointercancel", "pointercancel", releaseActiveDrag, true); + addTimelineWindowListener("pointerRelease.blur", "blur", releaseActiveDrag, true); +} + +export function installKeyboardShortcuts() { + addTimelineWindowListener("keyboardShortcuts.keydown", "keydown", (event) => { + const rawKey = String(event.key || ""); + const key = rawKey.toLowerCase(); + const arrowKey = rawKey === "ArrowLeft" || rawKey === "ArrowRight" || rawKey === "ArrowUp" || rawKey === "ArrowDown"; + if (key !== "d" && key !== "x" && !arrowKey) return; + if (event.target && ["INPUT", "TEXTAREA", "SELECT"].includes(event.target.tagName)) return; + const canvas = app.canvas; + const graphMouse = canvas ? canvas.graph_mouse : null; + if (!canvas || !graphMouse) return; + + if (arrowKey) { + const node = nodeFromGraphMouse(graphMouse); + const localPos = node ? [graphMouse[0] - node.pos[0], graphMouse[1] - node.pos[1]] : null; + if (node && isNodeSelected(node) && panPreviewWithKeyboard(node, event, localPos)) { + stopKeyboardShortcutEvent(event); + } + return; + } + + for (const node of graphNodes(activeCanvasGraph())) { + if (!isTargetNode(node)) continue; + if ( + graphMouse[0] < node.pos[0] || + graphMouse[0] > node.pos[0] + node.size[0] || + graphMouse[1] < node.pos[1] || + graphMouse[1] > node.pos[1] + node.size[1] + ) { + continue; + } + + const index = findKeyframeAt(node, [graphMouse[0] - node.pos[0], graphMouse[1] - node.pos[1]]); + if (index >= 0) { + const handled = key === "d" ? duplicateKeyframeAtIndex(node, index) : deleteKeyframeAtIndex(node, index); + if (handled) { + stopKeyboardShortcutEvent(event); + return; + } + } + } + }, { capture: true, passive: false }); +} diff --git a/web/ltxv_timeline/media.js b/web/ltxv_timeline/media.js new file mode 100644 index 0000000..3f1ce38 --- /dev/null +++ b/web/ltxv_timeline/media.js @@ -0,0 +1,603 @@ +// Image upload, drag/drop, paste, and graph-position media targeting. +import { api } from "../../../../scripts/api.js"; +import { app } from "../../../../scripts/app.js"; +import { LOCAL_MEDIA_MANAGER_DROP_TYPE } from "./constants.js"; +import { + activeCanvasGraph, + addTimelineWindowListener, + errorText, + graphNodes, + isTargetNode, + makeId, + setTimelineStatus, +} from "./runtime.js"; +import { + activeLayerIndex, + ensureSelectedKeyframe, + isEmptyLayer, + mirrorBaseLayer, + normalizeFileInfo, + normalizeKeyframes, + normalizeLayer, + preloadImage, + rejectLayerEditIfLocked, + selectedKeyframeIndex, + setSelectedKeyframe, + syncTimelineWidget, + uploadMode, + viewUrl, +} from "./model.js"; +import { bounds, formatSlotSeconds } from "./timeline_ops.js"; +export async function uploadSelectedImage(node, file, keyframeId, options = {}) { + if (rejectLayerEditIfLocked(node, "Image upload")) return false; + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const index = + keyframeId == null + ? selectedKeyframeIndex(node, keyframes) + : keyframes.findIndex((keyframe) => keyframe.id === keyframeId); + if (index == null || index < 0 || index >= keyframes.length) return; + + const body = new FormData(); + body.append("image", file); + body.append("type", "input"); + if (options.pasted) { + body.append("subfolder", "pasted"); + } else { + body.append("overwrite", "true"); + } + const response = await api.fetchApi("/upload/image", { method: "POST", body }); + if (!response.ok) throw new Error(`Image upload failed: ${response.status}`); + const payload = await response.json(); + const currentKeyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const currentIndex = + keyframeId == null + ? selectedKeyframeIndex(node, currentKeyframes) + : currentKeyframes.findIndex((keyframe) => keyframe.id === keyframeId); + if (currentIndex == null || currentIndex < 0 || currentIndex >= currentKeyframes.length) return; + const uploaded = normalizeFileInfo(payload) || { + filename: payload.name || file.name, + subfolder: payload.subfolder || "", + type: payload.type || "input", + }; + const keyframe = currentKeyframes[currentIndex]; + const layers = Array.isArray(keyframe.layers) ? keyframe.layers : []; + const currentMode = uploadMode(node); + const layerIndex = activeLayerIndex(keyframe); + const activeEmptyIndex = layerIndex >= 0 && isEmptyLayer(layers[layerIndex]) ? layerIndex : -1; + if (activeEmptyIndex >= 0) { + const currentLayer = layers[activeEmptyIndex]; + const layer = normalizeLayer({ ...currentLayer, image: uploaded, empty: false, fitMode: "crop", panX: 0, panY: 0, zoom: 1 }); + layers[activeEmptyIndex] = layer; + keyframe.layers = layers; + keyframe.selectedLayerId = layer.id; + } else if (currentMode === "layer" && layers.length) { + const layer = normalizeLayer({ id: makeId(), image: uploaded, fitMode: "crop", panX: 0, panY: 0, zoom: 1 }); + layers.push(layer); + keyframe.layers = layers; + keyframe.selectedLayerId = layer.id; + } else if (currentMode === "layer") { + const base = normalizeLayer({ id: makeId(), image: uploaded, fitMode: "crop", panX: 0, panY: 0, zoom: 1 }); + keyframe.layers = [base]; + keyframe.selectedLayerId = base.id; + } else if (layers.length) { + const replaceIndex = layerIndex >= 0 ? layerIndex : 0; + const nextLayer = normalizeLayer({ + ...layers[replaceIndex], + image: uploaded, + fitMode: "crop", + panX: 0, + panY: 0, + zoom: 1, + }); + layers[replaceIndex] = nextLayer; + keyframe.layers = layers; + keyframe.selectedLayerId = nextLayer.id; + } else { + const base = normalizeLayer({ id: makeId(), image: uploaded, fitMode: "crop", panX: 0, panY: 0, zoom: 1 }); + keyframe.layers = [base]; + keyframe.selectedLayerId = base.id; + } + mirrorBaseLayer(keyframe); + currentKeyframes[currentIndex] = keyframe; + node.properties.etk_ltxv_timeline_keyframes = currentKeyframes; + setSelectedKeyframe(node, currentIndex, currentKeyframes); + preloadImage(node, currentKeyframes[currentIndex]); + syncTimelineWidget(node); + const action = options.pasted ? "Pasted" : "Uploaded"; + const target = currentMode === "layer" ? "layer" : activeEmptyIndex >= 0 ? "empty layer" : "active layer"; + setTimelineStatus(node, `${action} ${uploaded.filename || uploaded.name || "image"} to ${target} at ${formatSlotSeconds(node, keyframe.slot)}.`, "success"); + node.setDirtyCanvas(true, true); +} + +export function nodeFromGraphMouse(graphMouse) { + if (!graphMouse) return null; + for (const node of graphNodes(activeCanvasGraph())) { + if (!isTargetNode(node)) continue; + if ( + graphMouse[0] >= node.pos[0] && + graphMouse[0] <= node.pos[0] + node.size[0] && + graphMouse[1] >= node.pos[1] && + graphMouse[1] <= node.pos[1] + node.size[1] + ) { + return node; + } + } + return null; +} + +export function eventToGraphPosition(event) { + const canvas = app.canvas; + const canvasElement = canvas ? canvas.canvas || canvas.canvas_element : null; + if (!canvas) return null; + if (!event || !Number.isFinite(event.clientX) || !Number.isFinite(event.clientY)) { + return canvas.graph_mouse ? [canvas.graph_mouse[0], canvas.graph_mouse[1]] : null; + } + + let offset = null; + if (typeof canvas.convertEventToCanvasOffset === "function") { + offset = canvas.convertEventToCanvasOffset(event); + } + if (!offset) { + const rect = canvasElement ? canvasElement.getBoundingClientRect() : { left: 0, top: 0 }; + offset = [event.clientX - rect.left, event.clientY - rect.top]; + } + if (!Array.isArray(offset)) { + const offsetX = offset[0] != null ? offset[0] : offset.x; + const offsetY = offset[1] != null ? offset[1] : offset.y; + offset = [offsetX, offsetY]; + } + if (!Number.isFinite(offset[0]) || !Number.isFinite(offset[1])) return null; + if (canvas.ds && typeof canvas.ds.convertOffsetToCanvas === "function") { + return canvas.ds.convertOffsetToCanvas(offset); + } + if (typeof canvas.convertOffsetToCanvas === "function") { + return canvas.convertOffsetToCanvas(offset); + } + if (canvas.ds && canvas.ds.offset && canvas.ds.scale) { + return [(offset[0] - canvas.ds.offset[0]) / canvas.ds.scale, (offset[1] - canvas.ds.offset[1]) / canvas.ds.scale]; + } + return offset; +} + +export function previewTargetFromGraphPosition(graphPos) { + if (!graphPos) return null; + for (const node of graphNodes(activeCanvasGraph())) { + if (!isTargetNode(node)) continue; + const localPos = [graphPos[0] - node.pos[0], graphPos[1] - node.pos[1]]; + if (isPreviewPosition(node, localPos) || isTimelineImageInputPosition(node, graphPos)) { + return { node, localPos }; + } + } + return null; +} + +export function isPreviewPosition(node, localPos) { + if (!node || !localPos) return false; + const { padX, width, previewY, previewH } = bounds(node); + const [x, y] = localPos; + return x >= padX && x <= padX + width && y >= previewY && y <= previewY + previewH; +} + +export function isTimelineImageInput(input) { + if (!input) return false; + const name = String(input.name || "").toLowerCase(); + const type = String(input.type || "").toUpperCase(); + return type === "IMAGE" || name === "image" || name === "images"; +} + +export function graphInputSlotPosition(node, input, index) { + if (typeof node.getInputPos === "function") return node.getInputPos(index); + if (typeof node.getConnectionPos === "function") return node.getConnectionPos(true, index); + if (Array.isArray(input.pos)) return [node.pos[0] + input.pos[0], node.pos[1] + input.pos[1]]; + return [node.pos[0] + 10, node.pos[1] + (index + 0.7) * 20]; +} + +export function isTimelineImageInputPosition(node, graphPos) { + if (!node || !graphPos || !node.inputs) return false; + if (typeof node.getSlotInPosition === "function") { + const slot = node.getSlotInPosition(graphPos[0], graphPos[1]); + if (slot && isTimelineImageInput(slot.input)) return true; + } + if (typeof node.getInputOnPos === "function") { + const input = node.getInputOnPos(graphPos); + if (isTimelineImageInput(input)) return true; + } + for (let i = 0; i < node.inputs.length; i++) { + const input = node.inputs[i]; + if (!isTimelineImageInput(input)) continue; + const pos = graphInputSlotPosition(node, input, i); + if (Math.abs(graphPos[0] - pos[0]) <= 18 && Math.abs(graphPos[1] - pos[1]) <= 14) return true; + } + return false; +} + +export function previewTargetFromEvent(event) { + const graphPos = eventToGraphPosition(event); + const target = previewTargetFromGraphPosition(graphPos); + if (target) window.etkTimelineLastPreviewTarget = target; + return target; +} + +export function currentPreviewTargetFromPointer() { + const canvas = app.canvas; + const graphMouse = canvas ? canvas.graph_mouse : null; + return previewTargetFromGraphPosition(graphMouse); +} + +export function isSupportedImageFile(file) { + if (!file) return false; + if (file.type && /^image\/(png|jpe?g|webp)$/i.test(file.type)) return true; + return /\.(png|jpe?g|webp)$/i.test(file.name || ""); +} + +export function dragUriTypes(dataTransfer) { + if (!dataTransfer || !dataTransfer.types) return []; + const types = Array.from(dataTransfer.types); + return types.filter( + (type) => + type === "text/uri-list" || + type === "text/x-moz-url" || + type === "text/plain" || + type === LOCAL_MEDIA_MANAGER_DROP_TYPE + ); +} + +export function localMediaManagerUri(raw) { + if (!raw) return ""; + try { + const payload = JSON.parse(raw); + const path = payload && (payload.path || payload.filepath || payload.filePath); + if (!path) return payload && payload.url ? String(payload.url) : ""; + const base = typeof location !== "undefined" && location.href ? location.href : window.location && window.location.href; + const url = new URL("/local_image_gallery/view", base); + url.searchParams.set("filepath", path); + return url.toString(); + } catch { + return String(raw || "").trim(); + } +} + +export function dragUriFromPayload(dataTransfer) { + const types = dragUriTypes(dataTransfer); + if (types.includes(LOCAL_MEDIA_MANAGER_DROP_TYPE)) { + const raw = dataTransfer.getData ? dataTransfer.getData(LOCAL_MEDIA_MANAGER_DROP_TYPE) : ""; + const uri = localMediaManagerUri(raw); + if (uri) return uri; + } + for (const type of types) { + if (type === LOCAL_MEDIA_MANAGER_DROP_TYPE) continue; + const raw = dataTransfer.getData ? dataTransfer.getData(type) : ""; + const uri = String(raw || "").split("\n").find((line) => line.trim() && !line.trim().startsWith("#")); + if (uri) return uri.trim(); + } + return ""; +} + +export function normalizeDropUrl(url) { + if (!url) return null; + if (/\/local_image_gallery\/thumbnail$/i.test(url.pathname) && url.searchParams.has("filepath")) { + const viewUrl = new URL("/local_image_gallery/view", url); + viewUrl.searchParams.set("filepath", url.searchParams.get("filepath")); + return viewUrl; + } + return url; +} + +export function isSupportedDropUrl(url) { + if (!url) return false; + if (/\/local_image_gallery\/view$/i.test(url.pathname)) return url.searchParams.has("filepath"); + if (/\/view$/i.test(url.pathname)) return url.searchParams.has("filepath") || url.searchParams.has("filename"); + if (url.searchParams.has("filename")) return true; + return /\.(png|jpe?g|webp)$/i.test(url.pathname); +} + +export function sameOriginDropUrl(dataTransfer) { + const uri = dragUriFromPayload(dataTransfer); + if (!uri) return null; + try { + const base = typeof location !== "undefined" && location.href ? location.href : window.location && window.location.href; + const url = new URL(uri, base); + const origin = typeof location !== "undefined" && location.origin ? location.origin : window.location && window.location.origin; + if (origin && url.origin !== origin) return null; + const normalizedUrl = normalizeDropUrl(url); + if (!isSupportedDropUrl(normalizedUrl)) return null; + return normalizedUrl; + } catch { + return null; + } +} + +export function fileNameFromDropUrl(url) { + const filepath = url.searchParams.get("filepath") || url.searchParams.get("filename") || url.pathname; + const cleanPath = String(filepath || "image.png").split(/[\\/]/).filter(Boolean).pop() || "image.png"; + return /\.(png|jpe?g|webp)$/i.test(cleanPath) ? cleanPath : `${cleanPath}.png`; +} + +export async function imageFileFromDropUrl(url) { + if (!url || typeof fetch !== "function") return null; + try { + const response = await fetch(url.toString()); + if (!response || !response.ok) return null; + const blob = await response.blob(); + const file = new File([blob], fileNameFromDropUrl(url), { type: blob.type || "image/png" }); + return isSupportedImageFile(file) ? file : null; + } catch { + return null; + } +} + +export function hasImageFilePayload(dataTransfer) { + if (!dataTransfer) return false; + if (dataTransfer.items && dataTransfer.items.length) { + const items = Array.from(dataTransfer.items); + if (items.some( + (item) => + (item.kind === "file" && (!item.type || /^image\/(png|jpe?g|webp)$/i.test(item.type))) || + (item.kind === "string" && + (item.type === "text/uri-list" || + item.type === "text/x-moz-url" || + item.type === "text/plain" || + item.type === LOCAL_MEDIA_MANAGER_DROP_TYPE)) + )) { + return true; + } + } + if (dataTransfer.files && dataTransfer.files.length) return Array.from(dataTransfer.files).some(isSupportedImageFile); + if (dataTransfer.types && Array.from(dataTransfer.types).includes("Files")) return true; + return dragUriTypes(dataTransfer).length > 0; +} + +export function imageFileFromPayload(payload) { + if (!payload) return null; + if (payload.files && payload.files.length) { + const file = Array.from(payload.files).find(isSupportedImageFile); + if (file) return file; + } + if (payload.items && payload.items.length) { + for (const item of Array.from(payload.items)) { + if (item.kind !== "file") continue; + const file = item.getAsFile ? item.getAsFile() : null; + if (isSupportedImageFile(file)) return file; + } + } + return null; +} + +export function shouldPreferDropUrl(payload, dropUrl) { + if (!dropUrl) return false; + const types = payload && payload.types ? Array.from(payload.types) : []; + if (types.includes(LOCAL_MEDIA_MANAGER_DROP_TYPE)) return true; + return /\/local_image_gallery\/view$/i.test(dropUrl.pathname) && dropUrl.searchParams.has("filepath"); +} + +export async function imageFileFromDropPayload(payload, dropUrl = null) { + const resolvedDropUrl = dropUrl || sameOriginDropUrl(payload); + if (shouldPreferDropUrl(payload, resolvedDropUrl)) { + return (await imageFileFromDropUrl(resolvedDropUrl)) || imageFileFromPayload(payload); + } + return imageFileFromPayload(payload) || imageFileFromDropUrl(resolvedDropUrl); +} + +export function setDropHoverNode(node) { + if (window.etkTimelineDropHoverNode === node) return; + if (window.etkTimelineDropHoverNode) { + window.etkTimelineDropHoverNode.etkTimelineDropHover = false; + window.etkTimelineDropHoverNode.setDirtyCanvas(true, true); + } + window.etkTimelineDropHoverNode = node || null; + if (node) { + node.etkTimelineDropHover = true; + node.setDirtyCanvas(true, true); + } +} + +export function clearDropHoverNode() { + setDropHoverNode(null); +} + +export function lastPreviewTarget() { + const target = window.etkTimelineLastPreviewTarget; + if (!target || !target.node || !target.node.graph) return null; + return target; +} + +export function isEditableClipboardTarget(target) { + if (!target) return false; + const tagName = String(target.tagName || "").toUpperCase(); + if (target.isContentEditable) return true; + if (tagName === "TEXTAREA" || tagName === "SELECT") return true; + if (tagName !== "INPUT") return false; + const inputType = String(target.type || "text").toLowerCase(); + return !["button", "checkbox", "file", "hidden", "image", "radio", "range", "reset", "search", "submit"].includes(inputType); +} + +export function hasSelectedDocumentText() { + if (typeof window.getSelection !== "function") return false; + const selection = window.getSelection(); + return !!selection && String(selection.toString ? selection.toString() : "").trim().length > 0; +} + +export function shouldIgnoreClipboardEvent(event) { + if (!event || event.defaultPrevented) return true; + if (event.shiftKey) return true; + return isEditableClipboardTarget(event.target) || hasSelectedDocumentText(); +} + +export function stopDropOrPasteEvent(event) { + event.preventDefault(); + event.stopPropagation(); + if (event.stopImmediatePropagation) event.stopImmediatePropagation(); +} + +export function stopKeyboardShortcutEvent(event) { + event.preventDefault(); + event.stopPropagation(); + if (event.stopImmediatePropagation) event.stopImmediatePropagation(); +} + +export async function uploadImageToNode(node, file, options = {}) { + if (!node || !file) return false; + if (rejectLayerEditIfLocked(node, "Image upload")) return false; + const keyframe = ensureSelectedKeyframe(node); + if (!keyframe) { + setTimelineStatus(node, "No free keyframe positions are available for this image.", "error"); + return false; + } + await uploadSelectedImage(node, file, keyframe.id, options); + return true; +} + +export async function uploadImageToPreviewTarget(target, file, options = {}) { + if (!target || !file) return false; + return uploadImageToNode(target.node, file, options); +} + +export function installTimelinePasteFiles(node) { + if (node.etkTimelinePasteFilesInstalled) return; + const previousPasteFiles = typeof node.pasteFiles === "function" ? node.pasteFiles : null; + const installedPasteFiles = function (files) { + const file = Array.from(files || []).find(isSupportedImageFile); + if (!file) return previousPasteFiles ? previousPasteFiles.call(this, files) : false; + void uploadImageToNode(this || node, file, { pasted: true }).catch((error) => { + setTimelineStatus(this || node, `Pasted image upload failed: ${errorText(error)}`, "error"); + console.error("[ETK timeline] Pasted image upload failed", error); + }); + return true; + }; + node.pasteFiles = installedPasteFiles; + node.etkTimelinePasteFilesInstalled = true; + + const previousOnRemoved = node.onRemoved; + node.onRemoved = function () { + if (this.pasteFiles === installedPasteFiles) this.pasteFiles = previousPasteFiles || undefined; + if (previousOnRemoved) return previousOnRemoved.apply(this, arguments); + }; +} + +export function installTimelineDragDropHooks(node) { + if (node.etkTimelineDragDropInstalled) return; + const previousOnDragOver = typeof node.onDragOver === "function" ? node.onDragOver : null; + const previousOnDragDrop = typeof node.onDragDrop === "function" ? node.onDragDrop : null; + + const installedOnDragOver = function (event) { + if (hasImageFilePayload(event && event.dataTransfer)) { + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + return true; + } + return previousOnDragOver ? previousOnDragOver.apply(this, arguments) : false; + }; + + const installedOnDragDrop = async function (event, claimEvent = true) { + const payload = event && event.dataTransfer; + const file = imageFileFromPayload(payload); + const dropUrl = sameOriginDropUrl(payload); + if (!file && !dropUrl) { + return previousOnDragDrop ? previousOnDragDrop.apply(this, arguments) : false; + } + if (claimEvent) stopDropOrPasteEvent(event); + try { + const resolvedFile = await imageFileFromDropPayload(payload, dropUrl); + if (!resolvedFile) return false; + await uploadImageToNode(this || node, resolvedFile); + return true; + } catch (error) { + setTimelineStatus(this || node, `Dropped image upload failed: ${errorText(error)}`, "error"); + console.error("[ETK timeline] Dropped image upload failed", error); + return false; + } + }; + + node.onDragOver = installedOnDragOver; + node.onDragDrop = installedOnDragDrop; + node.etkTimelineDragDropInstalled = true; + + const previousOnRemoved = node.onRemoved; + node.onRemoved = function () { + if (this.onDragOver === installedOnDragOver) this.onDragOver = previousOnDragOver || undefined; + if (this.onDragDrop === installedOnDragDrop) this.onDragDrop = previousOnDragDrop || undefined; + if (previousOnRemoved) return previousOnRemoved.apply(this, arguments); + }; +} + +export function installFileDropCapture() { + addTimelineWindowListener( + "fileDrop.mousemove", + "mousemove", + (event) => { + const target = previewTargetFromEvent(event); + if (target) window.etkTimelineLastPreviewTarget = target; + }, + { capture: true, passive: true } + ); + addTimelineWindowListener( + "fileDrop.dragover", + "dragover", + (event) => { + const target = hasImageFilePayload(event.dataTransfer) ? previewTargetFromEvent(event) : null; + if (!target) { + clearDropHoverNode(); + return; + } + setDropHoverNode(target.node); + if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; + stopDropOrPasteEvent(event); + }, + { capture: true, passive: false } + ); + addTimelineWindowListener( + "fileDrop.drop", + "drop", + async (event) => { + const target = previewTargetFromEvent(event); + const file = target ? imageFileFromPayload(event.dataTransfer) : null; + const dropUrl = target ? sameOriginDropUrl(event.dataTransfer) : null; + clearDropHoverNode(); + if (!target || (!file && !dropUrl)) return; + stopDropOrPasteEvent(event); + try { + const resolvedFile = await imageFileFromDropPayload(event.dataTransfer, dropUrl); + if (!resolvedFile) return; + await uploadImageToPreviewTarget(target, resolvedFile); + } catch (error) { + setTimelineStatus(target.node, `Dropped image upload failed: ${errorText(error)}`, "error"); + console.error("[ETK timeline] Dropped image upload failed", error); + } + }, + { capture: true, passive: false } + ); + addTimelineWindowListener("fileDrop.dragend", "dragend", clearDropHoverNode, true); + addTimelineWindowListener( + "fileDrop.dragleave", + "dragleave", + (event) => { + if (event.clientX <= 0 || event.clientY <= 0 || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight) { + clearDropHoverNode(); + } + }, + true + ); +} + +export function installClipboardPasteCapture() { + addTimelineWindowListener( + "clipboardPaste.paste", + "paste", + async (event) => { + if (shouldIgnoreClipboardEvent(event)) return; + const target = currentPreviewTargetFromPointer(); + const file = target ? imageFileFromPayload(event.clipboardData) : null; + const dropUrl = target ? sameOriginDropUrl(event.clipboardData) : null; + if (!target || (!file && !dropUrl)) return; + stopDropOrPasteEvent(event); + try { + const resolvedFile = await imageFileFromDropPayload(event.clipboardData, dropUrl); + if (!resolvedFile) return; + await uploadImageToPreviewTarget(target, resolvedFile, { pasted: true }); + } catch (error) { + setTimelineStatus(target.node, `Pasted image upload failed: ${errorText(error)}`, "error"); + console.error("[ETK timeline] Pasted image upload failed", error); + } + }, + { capture: true, passive: false } + ); +} diff --git a/web/ltxv_timeline/model.js b/web/ltxv_timeline/model.js new file mode 100644 index 0000000..27ac47c --- /dev/null +++ b/web/ltxv_timeline/model.js @@ -0,0 +1,753 @@ +// Timeline/keyframe/layer normalization, serialization, selection, and mutation. +import { api } from "../../../../scripts/api.js"; +import { + CONTROL_TABS, + CONTROL_TAB_PROPERTY, + DEFAULT_TEXT_LAYER, + LAYER_LOCK_PROPERTY, + MAX_LAYER_ZOOM, + MIN_LAYER_ZOOM, + TEXT_LAYER_FIELDS, + TIMELINE_SCHEMA_VERSION, + UPLOAD_MODE_PROPERTY, +} from "./constants.js"; +import { + clamp, + getNumericControlValue, + getWidgetValue, + makeId, + setTimelineStatus, + setWidgetValue, +} from "./runtime.js"; +import { syncKeyframePromptEditor, syncTextLayerEditor } from "./editors.js"; +import { setupNode } from "./node_setup.js"; +import { nearestFreeSlot, slotToSeconds, timelineFps } from "./timeline_ops.js"; +export function stopDomEvent(event) { + if (!event) return; + event.stopPropagation(); +} + +export function normalizeFileInfo(fileInfo) { + if (!fileInfo) return null; + if (typeof fileInfo === "string") { + return { filename: fileInfo, subfolder: "", type: "input" }; + } + const filename = fileInfo.filename || fileInfo.name; + if (!filename) return null; + return { + filename, + subfolder: fileInfo.subfolder || "", + type: fileInfo.type || "input", + }; +} + +export function normalizeTime(value) { + const seconds = Number(value); + return Number.isFinite(seconds) ? Math.max(0, seconds) : 0; +} + +export function keyframeTime(keyframe, node = null) { + if (!keyframe || typeof keyframe !== "object") return 0; + if (keyframe.time != null) return normalizeTime(keyframe.time); + if (keyframe.slot != null) { + const slot = Number(keyframe.slot); + return Number.isFinite(slot) ? Math.max(0, slot) * 8 / timelineFps(node) : 0; + } + return 0; +} + +export function normalizeSlot(keyframe, node = null) { + if (keyframe && keyframe.time == null && keyframe.slot != null) { + const slot = Number(keyframe.slot); + return Number.isFinite(slot) ? Math.max(0, Math.round(slot)) : 0; + } + return Math.max(0, Math.round(keyframeTime(keyframe, node) * timelineFps(node) / 8)); +} + +export function normalizePan(value) { + const pan = Number(value); + return Number.isFinite(pan) ? pan : 0; +} + +export function normalizeZoom(value) { + const zoom = Number(value); + return Number.isFinite(zoom) ? clamp(zoom, MIN_LAYER_ZOOM, MAX_LAYER_ZOOM) : 1; +} + +export function normalizeFilterValue(value) { + const amount = Number(value); + return Number.isFinite(amount) ? clamp(amount, 0, 2) : 1; +} + +export function rawFilterFieldProvided(value, field) { + return Boolean(value && typeof value === "object" && Object.prototype.hasOwnProperty.call(value, field) && value[field] != null); +} + +export function parsedTimelinePayload(value, { unwrapUiArray = false } = {}) { + let source = value; + if (unwrapUiArray && Array.isArray(source)) source = source.length ? source[0] : null; + if (typeof source === "string") { + try { + source = JSON.parse(source || "[]"); + } catch { + source = []; + } + } + return source; +} + +export function timelinePayloadKeyframes(value, options = {}) { + const source = parsedTimelinePayload(value, options); + if (Array.isArray(source)) return source; + if (source && typeof source === "object" && Array.isArray(source.keyframes)) return source.keyframes; + return []; +} + +export function rawTimelineKeyframes(payload) { + return timelinePayloadKeyframes(payload, { unwrapUiArray: true }); +} + +export function timelineReloadSourceScore(value) { + const keyframes = rawTimelineKeyframes(value); + let layers = 0; + let textLayers = 0; + let textStyleFields = 0; + for (const keyframe of keyframes) { + if (!keyframe || typeof keyframe !== "object" || !Array.isArray(keyframe.layers)) continue; + for (const layer of keyframe.layers) { + if (!layer || typeof layer !== "object") continue; + layers += 1; + if (!isTextLayer(layer)) continue; + textLayers += 1; + for (const field of TEXT_LAYER_FIELDS) { + if (Object.prototype.hasOwnProperty.call(layer, field) && layer[field] != null) textStyleFields += 1; + } + } + } + return { keyframes: keyframes.length, layers, textLayers, textStyleFields }; +} + +export function timelineReloadSource(node, useWidgetValue = false) { + const widgetSource = getWidgetValue(node, "timeline_json", "[]"); + const propertySource = node.properties ? node.properties.etk_ltxv_timeline_keyframes : null; + if (useWidgetValue || propertySource == null) return widgetSource; + const propertyScore = timelineReloadSourceScore(propertySource); + const widgetScore = timelineReloadSourceScore(widgetSource); + if (propertyScore.keyframes === 0 && widgetScore.keyframes > 0) return widgetSource; + if (widgetScore.textLayers > propertyScore.textLayers && widgetScore.keyframes >= propertyScore.keyframes) return widgetSource; + if (widgetScore.textLayers === propertyScore.textLayers && widgetScore.textStyleFields > propertyScore.textStyleFields && widgetScore.keyframes >= propertyScore.keyframes) { + return widgetSource; + } + return propertySource; +} + +export function fileInfoKey(fileInfo) { + const normalized = normalizeFileInfo(fileInfo); + return normalized ? `${normalized.type}\n${normalized.subfolder}\n${normalized.filename}` : ""; +} + +export function layerMatchKey(layer) { + if (!layer || typeof layer !== "object") return ""; + if (layer.id) return `id:${layer.id}`; + const key = fileInfoKey(layer.image || layer.file || layer.filename); + return key ? `image:${key}` : ""; +} + +export function matchingExistingLayer(existingLayers, layer, index) { + const key = layerMatchKey(layer); + if (key) { + const matched = existingLayers.find((candidate) => layerMatchKey(candidate) === key); + if (matched) return matched; + } + return existingLayers[index] || null; +} + +export function matchingExistingKeyframe(existingKeyframes, keyframe, index) { + if (keyframe && typeof keyframe === "object" && keyframe.id) { + const matched = existingKeyframes.find((candidate) => candidate.id === keyframe.id); + if (matched) return matched; + } + const time = keyframeTime(keyframe); + const matchedByTime = existingKeyframes.find((candidate) => Math.abs(keyframeTime(candidate) - time) < 1e-6); + if (matchedByTime) return matchedByTime; + const slot = normalizeSlot(keyframe); + const matchedBySlot = existingKeyframes.find((candidate) => candidate.slot === slot); + if (matchedBySlot) return matchedBySlot; + return existingKeyframes[index] || null; +} + +export function preserveExecutedFilterFields(node, payload) { + const existingKeyframes = normalizeKeyframes(node.properties ? node.properties.etk_ltxv_timeline_keyframes : [], node); + const keyframes = rawTimelineKeyframes(payload); + if (!existingKeyframes.length || !keyframes.length) return normalizeKeyframes(payload, node); + + const merged = keyframes.map((keyframe, keyframeIndex) => { + if (!keyframe || typeof keyframe !== "object") return keyframe; + const existing = matchingExistingKeyframe(existingKeyframes, keyframe, keyframeIndex); + if (!existing) return keyframe; + + const mergedKeyframe = { ...keyframe }; + const keyframeHasBrightness = rawFilterFieldProvided(keyframe, "brightness"); + const keyframeHasContrast = rawFilterFieldProvided(keyframe, "contrast"); + if (!keyframeHasBrightness && existing.brightness != null) mergedKeyframe.brightness = existing.brightness; + if (!keyframeHasContrast && existing.contrast != null) mergedKeyframe.contrast = existing.contrast; + + if (Array.isArray(keyframe.layers)) { + const existingLayers = Array.isArray(existing.layers) ? existing.layers : []; + mergedKeyframe.layers = keyframe.layers.map((layer, layerIndex) => { + if (!layer || typeof layer !== "object") return layer; + const existingLayer = matchingExistingLayer(existingLayers, layer, layerIndex); + if (!existingLayer) return layer; + const mergedLayer = { ...layer }; + if (!rawFilterFieldProvided(layer, "brightness") && !keyframeHasBrightness && existingLayer.brightness != null) { + mergedLayer.brightness = existingLayer.brightness; + } + if (!rawFilterFieldProvided(layer, "contrast") && !keyframeHasContrast && existingLayer.contrast != null) { + mergedLayer.contrast = existingLayer.contrast; + } + return mergedLayer; + }); + } + + return mergedKeyframe; + }); + + return normalizeKeyframes(merged, node); +} + +export function normalizePromptStrength(value) { + const strength = Number(value); + return Number.isFinite(strength) ? clamp(strength, 0, 4) : 1; +} + +export function normalizeOptionalLatentStrength(keyframe) { + if (!keyframe || (keyframe.latentStrength == null && keyframe.latent_strength == null && keyframe.strength == null)) { + return undefined; + } + const strength = Number(keyframe.latentStrength != null ? keyframe.latentStrength : keyframe.latent_strength != null ? keyframe.latent_strength : keyframe.strength); + return Number.isFinite(strength) ? clamp(strength, 0, 1) : undefined; +} + +export function normalizeLatentStrengthValue(value, fallback = 1) { + const strength = Number(value); + if (Number.isFinite(strength)) return clamp(strength, 0, 1); + return clamp(Number(fallback) || 1, 0, 1); +} + +export function displayLatentStrength(node, keyframe) { + const explicit = normalizeOptionalLatentStrength(keyframe); + if (explicit != null) return explicit; + return normalizeLatentStrengthValue(getNumericControlValue(node, "strength", 1, { min: 0 }), 1); +} + +export function normalizePrompt(value) { + return typeof value === "string" ? value : value == null ? "" : String(value); +} + +export function normalizeTextLayerString(value, fallback = "") { + if (value == null) return String(fallback || ""); + return String(value); +} + +export function normalizeTextLayerFontFamily(value, fallback = DEFAULT_TEXT_LAYER.fontFamily) { + const family = normalizeTextLayerString(value, fallback).trim(); + return family || DEFAULT_TEXT_LAYER.fontFamily; +} + +export function normalizeTextLayerFontSize(value, fallback = DEFAULT_TEXT_LAYER.fontSize) { + const size = Number(value); + return Number.isFinite(size) ? clamp(size, 1, 512) : clamp(Number(fallback) || DEFAULT_TEXT_LAYER.fontSize, 1, 512); +} + +export function normalizeTextLayerBool(value, fallback = false) { + if (typeof value === "boolean") return value; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes", "bold", "italic", "on"].includes(normalized)) return true; + if (["false", "0", "no", "normal", "off"].includes(normalized)) return false; + } + if (typeof value === "number") return value !== 0; + return Boolean(fallback); +} + +export function normalizeTextLayerBold(value, fallback = DEFAULT_TEXT_LAYER.bold) { + return normalizeTextLayerBool(value, fallback); +} + +export function normalizeTextLayerItalic(value, fallback = DEFAULT_TEXT_LAYER.italic) { + return normalizeTextLayerBool(value, fallback); +} + +export function normalizeTextLayerColor(value, fallback = DEFAULT_TEXT_LAYER.color) { + const text = normalizeTextLayerString(value, fallback).trim(); + const match = text.match(/^#?([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/); + return match ? `#${match[1].toLowerCase()}` : fallback; +} + +export function normalizeTextLayerAlign(value, fallback = DEFAULT_TEXT_LAYER.align) { + const normalized = normalizeTextLayerString(value, fallback).trim().toLowerCase(); + return ["left", "center", "right"].includes(normalized) ? normalized : fallback; +} + +export function normalizeTextLayerNumber(value, fallback, min, max) { + const number = Number(value); + const base = Number.isFinite(number) ? number : Number(fallback); + return clamp(Number.isFinite(base) ? base : min, min, max); +} + +export function ensureTextLayerFontOption(select, value) { + if (!select) return; + const font = normalizeTextLayerFontFamily(value, DEFAULT_TEXT_LAYER.fontFamily); + const options = Array.from(select.options || select.children || []); + if (options.some((option) => option && option.value === font)) return; + const option = document.createElement("option"); + option.value = font; + option.textContent = font; + select.appendChild(option); +} + +export function isTextLayer(layer) { + if (!layer || typeof layer !== "object") return false; + const layerType = String(layer.type != null ? layer.type : layer.layerType != null ? layer.layerType : layer.layer_type != null ? layer.layer_type : "").toLowerCase(); + if (layerType === "text") return true; + return TEXT_LAYER_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(layer, field)); +} + +export function normalizeLayer(layer, fallback = {}) { + const source = layer && typeof layer === "object" ? layer : {}; + const textLayer = isTextLayer(source) || fallback.type === "text"; + const image = textLayer ? null : normalizeFileInfo(source.image || source.file || source.filename || fallback.image); + const hasLayerSlot = + image || + textLayer || + source.id || + source.empty || + Object.prototype.hasOwnProperty.call(source, "image") || + Object.prototype.hasOwnProperty.call(source, "file") || + Object.prototype.hasOwnProperty.call(source, "filename") || + Object.prototype.hasOwnProperty.call(source, "type") || + Object.prototype.hasOwnProperty.call(source, "layerType") || + Object.prototype.hasOwnProperty.call(source, "layer_type") || + TEXT_LAYER_FIELDS.some((field) => Object.prototype.hasOwnProperty.call(source, field)); + if (!hasLayerSlot) return null; + const normalized = { + id: source.id || fallback.id || makeId(), + type: textLayer ? "text" : "image", + image, + empty: !image && !textLayer, + fitMode: source.fitMode === "pad" || source.fit_mode === "pad" || fallback.fitMode === "pad" ? "pad" : "crop", + panX: normalizePan(source.panX != null ? source.panX : source.pan_x != null ? source.pan_x : fallback.panX), + panY: normalizePan(source.panY != null ? source.panY : source.pan_y != null ? source.pan_y : fallback.panY), + zoom: normalizeZoom(source.zoom != null ? source.zoom : fallback.zoom), + brightness: normalizeFilterValue(source.brightness != null ? source.brightness : fallback.brightness), + contrast: normalizeFilterValue(source.contrast != null ? source.contrast : fallback.contrast), + }; + if (textLayer) { + normalized.text = normalizeTextLayerString(source.text, fallback.text != null ? fallback.text : DEFAULT_TEXT_LAYER.text); + normalized.fontFamily = normalizeTextLayerFontFamily(source.fontFamily != null ? source.fontFamily : source.font_family, fallback.fontFamily || fallback.font_family || DEFAULT_TEXT_LAYER.fontFamily); + normalized.fontSize = normalizeTextLayerFontSize(source.fontSize != null ? source.fontSize : source.font_size, fallback.fontSize || fallback.font_size || DEFAULT_TEXT_LAYER.fontSize); + normalized.bold = normalizeTextLayerBold(source.bold, fallback.bold); + normalized.italic = normalizeTextLayerItalic(source.italic, fallback.italic); + normalized.align = normalizeTextLayerAlign(source.align != null ? source.align : source.textAlign != null ? source.textAlign : source.text_align, fallback.align || fallback.textAlign || fallback.text_align || DEFAULT_TEXT_LAYER.align); + normalized.color = normalizeTextLayerColor(source.color, fallback.color || DEFAULT_TEXT_LAYER.color); + normalized.outlineColor = normalizeTextLayerColor(source.outlineColor != null ? source.outlineColor : source.outline_color, fallback.outlineColor || fallback.outline_color || DEFAULT_TEXT_LAYER.outlineColor); + normalized.outlineWidth = normalizeTextLayerNumber(source.outlineWidth != null ? source.outlineWidth : source.outline_width, fallback.outlineWidth != null ? fallback.outlineWidth : fallback.outline_width != null ? fallback.outline_width : DEFAULT_TEXT_LAYER.outlineWidth, 0, 64); + normalized.shadowColor = normalizeTextLayerColor(source.shadowColor != null ? source.shadowColor : source.shadow_color, fallback.shadowColor || fallback.shadow_color || DEFAULT_TEXT_LAYER.shadowColor); + normalized.shadowBlur = normalizeTextLayerNumber(source.shadowBlur != null ? source.shadowBlur : source.shadow_blur, fallback.shadowBlur != null ? fallback.shadowBlur : fallback.shadow_blur != null ? fallback.shadow_blur : DEFAULT_TEXT_LAYER.shadowBlur, 0, 128); + normalized.shadowOffsetX = normalizeTextLayerNumber(source.shadowOffsetX != null ? source.shadowOffsetX : source.shadow_offset_x, fallback.shadowOffsetX != null ? fallback.shadowOffsetX : fallback.shadow_offset_x != null ? fallback.shadow_offset_x : DEFAULT_TEXT_LAYER.shadowOffsetX, -512, 512); + normalized.shadowOffsetY = normalizeTextLayerNumber(source.shadowOffsetY != null ? source.shadowOffsetY : source.shadow_offset_y, fallback.shadowOffsetY != null ? fallback.shadowOffsetY : fallback.shadow_offset_y != null ? fallback.shadow_offset_y : DEFAULT_TEXT_LAYER.shadowOffsetY, -512, 512); + } + return normalized; +} + +export function mirrorBaseLayer(keyframe) { + const layers = Array.isArray(keyframe.layers) ? keyframe.layers : []; + const base = layers[0] || null; + keyframe.image = base ? base.image : normalizeFileInfo(keyframe.image); + keyframe.fitMode = base ? base.fitMode : keyframe.fitMode === "pad" ? "pad" : "crop"; + keyframe.panX = base ? base.panX : normalizePan(keyframe.panX); + keyframe.panY = base ? base.panY : normalizePan(keyframe.panY); + keyframe.zoom = base ? base.zoom : normalizeZoom(keyframe.zoom); + keyframe.brightness = base ? normalizeFilterValue(base.brightness) : normalizeFilterValue(keyframe.brightness); + keyframe.contrast = base ? normalizeFilterValue(base.contrast) : normalizeFilterValue(keyframe.contrast); + if (layers.length) { + const selectedLayerId = keyframe.selectedLayerId || keyframe.selected_layer_id; + keyframe.selectedLayerId = layers.some((layer) => layer.id === selectedLayerId) ? selectedLayerId : layers[0].id; + } else { + delete keyframe.selectedLayerId; + } + return keyframe; +} + +export function normalizeLayers(keyframe, image, fallback) { + const layers = []; + if (Array.isArray(keyframe.layers)) { + for (const layer of keyframe.layers) { + const normalized = normalizeLayer(layer, { ...fallback, image: null }); + if (normalized) layers.push(normalized); + } + } + if (!layers.length && image) { + const normalized = normalizeLayer({ image }, { ...fallback, id: keyframe.selectedLayerId || keyframe.selected_layer_id || makeId() }); + if (normalized) layers.push(normalized); + } + return layers; +} + +export function normalizeKeyframes(value, node = null) { + const source = timelinePayloadKeyframes(value); + + return source + .map((keyframe) => { + if (!keyframe || typeof keyframe !== "object") return null; + const image = normalizeFileInfo(keyframe.image || keyframe.file || keyframe.filename); + const fallback = { + image, + fitMode: keyframe.fitMode === "pad" || keyframe.fit_mode === "pad" ? "pad" : "crop", + panX: keyframe.panX != null ? keyframe.panX : keyframe.pan_x, + panY: keyframe.panY != null ? keyframe.panY : keyframe.pan_y, + zoom: keyframe.zoom, + brightness: keyframe.brightness, + contrast: keyframe.contrast, + }; + const normalized = { + id: keyframe.id || makeId(), + time: keyframeTime(keyframe, node), + slot: normalizeSlot(keyframe, node), + fitMode: fallback.fitMode, + panX: normalizePan(fallback.panX), + panY: normalizePan(fallback.panY), + zoom: normalizeZoom(fallback.zoom), + brightness: normalizeFilterValue(fallback.brightness), + contrast: normalizeFilterValue(fallback.contrast), + positivePrompt: normalizePrompt(keyframe.positivePrompt != null ? keyframe.positivePrompt : keyframe.positive_prompt), + promptStrength: normalizePromptStrength(keyframe.promptStrength != null ? keyframe.promptStrength : keyframe.prompt_strength), + inputImage: Boolean(keyframe.inputImage || keyframe.input_image || keyframe.connectedImage || keyframe.connected_image), + selectedLayerId: keyframe.selectedLayerId || keyframe.selected_layer_id, + layers: normalizeLayers(keyframe, image, fallback), + image, + }; + mirrorBaseLayer(normalized); + const latentStrength = normalizeOptionalLatentStrength(keyframe); + if (latentStrength != null) normalized.latentStrength = latentStrength; + return normalized; + }) + .filter(Boolean) + .sort((a, b) => a.time - b.time || a.slot - b.slot); +} + +export function serializableTimelineKeyframes(node, keyframes = null) { + return (keyframes || normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node)).map((keyframe) => { + const serialized = { ...keyframe, time: normalizeTime(keyframe.time) }; + delete serialized.slot; + delete serialized.frame; + return serialized; + }); +} + +export function serializableTimelinePayload(node, keyframes = null) { + return { + schemaVersion: TIMELINE_SCHEMA_VERSION, + keyframes: serializableTimelineKeyframes(node, keyframes), + }; +} + +export function serializedKeyframes(node) { + return JSON.stringify(serializableTimelinePayload(node)); +} + +export function syncTimelineWidget(node) { + setWidgetValue(node, "timeline_json", serializedKeyframes(node)); +} + +export function refreshTimeline(node) { + setupNode(node); + node.properties.etk_ltxv_timeline_keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const keyframes = node.properties.etk_ltxv_timeline_keyframes; + let selectedIndex = selectedKeyframeIndex(node, keyframes); + if (selectedIndex < 0 && keyframes.length) selectedIndex = 0; + if (selectedIndex >= keyframes.length) selectedIndex = keyframes.length - 1; + if (selectedIndex >= 0) { + setSelectedKeyframe(node, selectedIndex, keyframes); + } else { + setSelectedKeyframe(node, -1, keyframes); + } + for (const keyframe of keyframes) preloadImage(node, keyframe); + syncTimelineWidget(node); + setTimelineStatus(node, "Timeline previews refreshed.", "success"); + node.setDirtyCanvas(true, true); + if (node.graph && typeof node.graph.setDirtyCanvas === "function") { + node.graph.setDirtyCanvas(true, true); + } +} + +export function viewUrl(fileInfo) { + const normalized = normalizeFileInfo(fileInfo); + if (!normalized) return null; + const params = new URLSearchParams(); + params.set("filename", normalized.filename); + params.set("type", normalized.type); + if (normalized.subfolder) params.set("subfolder", normalized.subfolder); + return api.apiURL(`/view?${params.toString()}`); +} + +export function preloadImage(node, keyframe) { + if (!keyframe) return; + if (!node.etkTimelineImageCache) node.etkTimelineImageCache = new Map(); + const files = []; + if (Array.isArray(keyframe.layers)) { + for (const layer of keyframe.layers) files.push(layer.image); + } + if (!files.length && keyframe.image) files.push(keyframe.image); + for (const fileInfo of files) { + const url = viewUrl(fileInfo); + if (!url || node.etkTimelineImageCache.get(url)) continue; + const image = new Image(); + image.onload = () => node.setDirtyCanvas(true, true); + image.onerror = () => { + image.etkTimelineLoadFailed = true; + node.setDirtyCanvas(true, true); + }; + image.src = url; + node.etkTimelineImageCache.set(url, image); + } +} + +export function setSelectedKeyframe(node, index, keyframes) { + const frames = keyframes || normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + if (index == null || index < 0 || index >= frames.length) { + node.properties.etk_ltxv_selected_keyframe = -1; + node.properties.etk_ltxv_selected_keyframe_id = null; + syncKeyframePromptEditor(node); + syncTextLayerEditor(node); + return; + } + node.properties.etk_ltxv_selected_keyframe = index; + node.properties.etk_ltxv_selected_keyframe_id = frames[index].id; + syncKeyframePromptEditor(node, frames[index]); + syncTextLayerEditor(node); +} + +export function selectedKeyframeIndex(node, keyframes) { + const frames = keyframes || normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const selectedId = node.properties.etk_ltxv_selected_keyframe_id; + if (selectedId) { + const idIndex = frames.findIndex((keyframe) => keyframe.id === selectedId); + if (idIndex >= 0) { + node.properties.etk_ltxv_selected_keyframe = idIndex; + return idIndex; + } + } + const index = node.properties.etk_ltxv_selected_keyframe; + if (index == null || index < 0 || index >= frames.length) return -1; + node.properties.etk_ltxv_selected_keyframe_id = frames[index].id; + return index; +} + +export function selectedKeyframe(node) { + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const index = selectedKeyframeIndex(node, keyframes); + return index >= 0 ? keyframes[index] : null; +} + +export function ensureSelectedKeyframe(node) { + setupNode(node); + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + let index = selectedKeyframeIndex(node, keyframes); + if (index >= 0) return keyframes[index]; + if (keyframes.length > 0) { + setSelectedKeyframe(node, 0, keyframes); + syncTimelineWidget(node); + node.setDirtyCanvas(true, true); + return keyframes[0]; + } + + const slot = nearestFreeSlot(node, 0); + if (slot == null) return null; + const keyframe = { id: makeId(), time: slotToSeconds(node, slot), slot, fitMode: "crop", panX: 0, panY: 0, zoom: 1, image: null, layers: [] }; + keyframes.push(keyframe); + node.properties.etk_ltxv_timeline_keyframes = keyframes; + setSelectedKeyframe(node, 0, keyframes); + syncTimelineWidget(node); + node.setDirtyCanvas(true, true); + return keyframe; +} + +export function updateKeyframe(node, keyframeId, changes) { + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const index = keyframes.findIndex((keyframe) => keyframe.id === keyframeId); + if (index < 0) return null; + keyframes[index] = { ...keyframes[index], ...changes }; + node.properties.etk_ltxv_timeline_keyframes = keyframes; + setSelectedKeyframe(node, index, keyframes); + syncTimelineWidget(node); + node.setDirtyCanvas(true, true); + return keyframes[index]; +} + +export function uploadMode(node) { + if (!node.properties) node.properties = {}; + if (node.properties[UPLOAD_MODE_PROPERTY] !== "layer") { + node.properties[UPLOAD_MODE_PROPERTY] = "replace"; + } + return node.properties[UPLOAD_MODE_PROPERTY]; +} + +export function toggleUploadMode(node) { + node.properties[UPLOAD_MODE_PROPERTY] = uploadMode(node) === "layer" ? "replace" : "layer"; + setTimelineStatus(node, node.properties[UPLOAD_MODE_PROPERTY] === "layer" ? "Upload mode: add new layers." : "Upload mode: replace active layer.", "info"); + node.setDirtyCanvas(true, true); +} + +export function layerEditingLocked(node) { + if (!node) return false; + if (!node.properties) node.properties = {}; + const locked = node.properties[LAYER_LOCK_PROPERTY] === true || node.properties[LAYER_LOCK_PROPERTY] === "true"; + node.properties[LAYER_LOCK_PROPERTY] = locked; + return locked; +} + +export function setLayerEditingLocked(node, locked) { + if (!node) return false; + if (!node.properties) node.properties = {}; + const next = Boolean(locked); + node.properties[LAYER_LOCK_PROPERTY] = next; + setTimelineStatus(node, next ? "Layer editing locked." : "Layer editing unlocked.", next ? "info" : "success"); + syncTextLayerEditor(node); + if (typeof node.setDirtyCanvas === "function") node.setDirtyCanvas(true, true); + if (node.graph && typeof node.graph.setDirtyCanvas === "function") node.graph.setDirtyCanvas(true, true); + return next; +} + +export function toggleLayerEditingLock(node) { + return setLayerEditingLocked(node, !layerEditingLocked(node)); +} + +export function rejectLayerEditIfLocked(node, action = "Layer edit") { + if (!layerEditingLocked(node)) return false; + setTimelineStatus(node, `${action} blocked: layer editing is locked.`, "error"); + if (typeof node.setDirtyCanvas === "function") node.setDirtyCanvas(true, true); + return true; +} + +export function normalizeControlsTab(value) { + const tab = String(value || ""); + if (tab === "image") return "layers"; + return CONTROL_TABS.some((candidate) => candidate.id === tab) ? tab : "strength"; +} + +export function controlsTab(node) { + if (!node.properties) node.properties = {}; + const tab = normalizeControlsTab(node.properties[CONTROL_TAB_PROPERTY]); + node.properties[CONTROL_TAB_PROPERTY] = tab; + return tab; +} + +export function setControlsTab(node, tab) { + if (!node || !node.properties) return false; + const next = normalizeControlsTab(tab); + node.properties[CONTROL_TAB_PROPERTY] = next; + if (typeof node.setDirtyCanvas === "function") node.setDirtyCanvas(true, true); + if (node.graph && typeof node.graph.setDirtyCanvas === "function") node.graph.setDirtyCanvas(true, true); + return true; +} + +export function keyframeHasImageSource(keyframe) { + return Boolean( + keyframe && + (keyframe.image || + keyframe.inputImage || + (Array.isArray(keyframe.layers) && keyframe.layers.some((layer) => layer.image || isTextLayer(layer)))) + ); +} + +export function activeLayerIndex(keyframe) { + const layers = Array.isArray(keyframe && keyframe.layers) ? keyframe.layers : []; + if (!layers.length) return -1; + const selectedLayerId = keyframe.selectedLayerId || keyframe.selected_layer_id; + const index = selectedLayerId ? layers.findIndex((layer) => layer.id === selectedLayerId) : -1; + return index >= 0 ? index : 0; +} + +export function activeLayer(keyframe) { + const index = activeLayerIndex(keyframe); + return index >= 0 ? keyframe.layers[index] : null; +} + +export function isEmptyLayer(layer) { + return !layer || (!isTextLayer(layer) && !normalizeFileInfo(layer.image)); +} + +export function updateActiveLayer(node, keyframeId, updater) { + if (rejectLayerEditIfLocked(node)) return null; + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const keyframeIndex = keyframes.findIndex((keyframe) => keyframe.id === keyframeId); + if (keyframeIndex < 0) return null; + const keyframe = keyframes[keyframeIndex]; + const layerIndex = activeLayerIndex(keyframe); + if (layerIndex < 0) return null; + const currentLayer = keyframe.layers[layerIndex]; + const nextLayer = updater({ ...currentLayer, image: currentLayer.image ? { ...currentLayer.image } : null }); + if (!nextLayer) return null; + keyframe.layers[layerIndex] = normalizeLayer(nextLayer, { id: currentLayer.id }) || keyframe.layers[layerIndex]; + keyframe.selectedLayerId = keyframe.layers[layerIndex].id; + mirrorBaseLayer(keyframe); + keyframes[keyframeIndex] = keyframe; + node.properties.etk_ltxv_timeline_keyframes = keyframes; + setSelectedKeyframe(node, keyframeIndex, keyframes); + syncTimelineWidget(node); + node.setDirtyCanvas(true, true); + return keyframe.layers[layerIndex]; +} + +export function setActiveLayerId(node, keyframeId, layerId) { + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const index = keyframes.findIndex((keyframe) => keyframe.id === keyframeId); + if (index < 0) return false; + if (!keyframes[index].layers.some((layer) => layer.id === layerId)) return false; + keyframes[index].selectedLayerId = layerId; + node.properties.etk_ltxv_timeline_keyframes = keyframes; + setSelectedKeyframe(node, index, keyframes); + syncTimelineWidget(node); + syncTextLayerEditor(node); + node.setDirtyCanvas(true, true); + return true; +} + +export function deleteActiveLayer(node, keyframeId) { + if (rejectLayerEditIfLocked(node, "Layer delete")) return false; + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const index = keyframes.findIndex((keyframe) => keyframe.id === keyframeId); + if (index < 0) return false; + const keyframe = keyframes[index]; + const layers = Array.isArray(keyframe.layers) ? keyframe.layers.slice() : []; + if (!layers.length) return false; + const layerIndex = activeLayerIndex(keyframe); + if (layerIndex < 0) return false; + if (isEmptyLayer(layers[layerIndex]) && layers.length > 1) { + layers.splice(layerIndex, 1); + } else { + layers[layerIndex] = normalizeLayer({ + id: layers[layerIndex].id, + image: null, + empty: true, + fitMode: "crop", + panX: 0, + panY: 0, + zoom: 1, + brightness: 1, + contrast: 1, + }, { id: layers[layerIndex].id }) || layers[layerIndex]; + } + const nextIndex = Math.min(layerIndex, layers.length - 1); + keyframe.layers = layers; + keyframe.selectedLayerId = layers[nextIndex] ? layers[nextIndex].id : null; + mirrorBaseLayer(keyframe); + keyframes[index] = keyframe; + node.properties.etk_ltxv_timeline_keyframes = keyframes; + setSelectedKeyframe(node, index, keyframes); + syncTimelineWidget(node); + syncTextLayerEditor(node); + node.setDirtyCanvas(true, true); + return true; +} diff --git a/web/ltxv_timeline/node_setup.js b/web/ltxv_timeline/node_setup.js new file mode 100644 index 0000000..67f9fff --- /dev/null +++ b/web/ltxv_timeline/node_setup.js @@ -0,0 +1,233 @@ +// Timeline widget creation, canvas sizing, and per-node setup. +import { + CANVAS_SIZE_PROPERTY, + OUTPUT_SETTINGS_PROPERTY, + TIMELINE_EDITOR_HEIGHT, + TIMELINE_EDITOR_WIDGET, +} from "./constants.js"; +import { + ensureUiInstanceId, + errorText, + getWidget, + getWidgetValue, + hideWidget, + normalizeTimelineSettings, + setTimelineStatus, +} from "./runtime.js"; +import { + controlsTab, + layerEditingLocked, + normalizeKeyframes, + preloadImage, + selectedKeyframe, + selectedKeyframeIndex, + setSelectedKeyframe, + syncTimelineWidget, + uploadMode, +} from "./model.js"; +import { + clarifyDefaultGuideStrengthWidget, + ensureKeyframePromptEditorWidget, + ensureTextLayerEditorWidget, + fixPromptWidgetHeights, + migrateDisableRelayWidgetOrder, + syncChainInheritedWidgets, + timelineEditorWidget, +} from "./editors.js"; +import { drawTimeline } from "./rendering.js"; +import { installTimelineDragDropHooks, installTimelinePasteFiles, uploadSelectedImage } from "./media.js"; +export function ensureTimelineEditorWidget(node) { + if (timelineEditorWidget(node)) return; + const widget = { + type: "custom", + name: TIMELINE_EDITOR_WIDGET, + value: null, + serialize: false, + options: { serialize: false, canvasOnly: true }, + computeLayoutSize() { + return { minHeight: TIMELINE_EDITOR_HEIGHT, minWidth: 460 }; + }, + draw(ctx, owner, width, y, widgetHeight) { + const height = Math.max( + Number.isFinite(this.computedHeight) + ? this.computedHeight + : Number.isFinite(widgetHeight) + ? widgetHeight + : TIMELINE_EDITOR_HEIGHT, + TIMELINE_EDITOR_HEIGHT + ); + this.last_y = y; + this.last_h = height; + this.computedHeight = height; + owner.etkTimelineEditorY = y; + owner.etkTimelineEditorHeight = height; + drawTimeline(owner, ctx, y, height); + }, + mouse(event, pos, owner) { + const type = event && event.type ? event.type : ""; + if (type.includes("move")) { + return owner.onMouseMove ? owner.onMouseMove(event, pos) : false; + } + if (type.includes("up")) { + return owner.onMouseUp ? owner.onMouseUp(event, pos) : false; + } + if (type.includes("down") || type === "mousedown" || !type) { + return owner.onMouseDown ? owner.onMouseDown(event, pos) : false; + } + return false; + }, + }; + if (typeof node.addCustomWidget === "function") { + node.addCustomWidget(widget); + } else { + if (!node.widgets) node.widgets = []; + node.widgets.push(widget); + } +} + +export function normalizeCanvasSize(value) { + if (!value || typeof value !== "object") return null; + const hasIndexedSize = value.length >= 2 || (0 in value && 1 in value); + const width = Number(hasIndexedSize ? value[0] : value.width ?? value.w); + const height = Number(hasIndexedSize ? value[1] : value.height ?? value.h); + if (!Number.isFinite(width) || !Number.isFinite(height)) return null; + return [Math.max(1, width), Math.max(1, height)]; +} + +export function configuredCanvasSize(config) { + if (!config || typeof config !== "object") return null; + return normalizeCanvasSize(config.size) || normalizeCanvasSize(config.properties && config.properties[CANVAS_SIZE_PROPERTY]); +} + +export function rememberTimelineCanvasSize(node, size = null) { + if (!node) return null; + if (!node.properties) node.properties = {}; + const normalized = normalizeCanvasSize(size || node.size); + if (!normalized) return null; + node.properties[CANVAS_SIZE_PROPERTY] = normalized; + return normalized; +} + +export function applyTimelineCanvasSize(node, preferredSize = null) { + if (!node) return null; + if (!node.properties) node.properties = {}; + const minimumSize = typeof node.computeSize === "function" ? node.computeSize() : [460, TIMELINE_EDITOR_HEIGHT]; + const restoredSize = + normalizeCanvasSize(preferredSize) || + normalizeCanvasSize(node.properties[CANVAS_SIZE_PROPERTY]) || + normalizeCanvasSize(node.size) || + minimumSize; + node.size = [ + Math.max(restoredSize[0], minimumSize[0]), + Math.max(restoredSize[1], minimumSize[1]), + ]; + return rememberTimelineCanvasSize(node); +} + +export function markTimelineCanvasDirty(node) { + if (!node) return; + if (typeof node.setDirtyCanvas === "function") node.setDirtyCanvas(true, true); + if (node.graph && typeof node.graph.setDirtyCanvas === "function") node.graph.setDirtyCanvas(true, true); +} + +export function scheduleTimelineCanvasSizeRestore(node, preferredSize = null) { + if (!node) return null; + if (!node.properties) node.properties = {}; + const targetSize = + normalizeCanvasSize(preferredSize) || + normalizeCanvasSize(node.properties[CANVAS_SIZE_PROPERTY]) || + normalizeCanvasSize(node.size); + if (!targetSize) return null; + + const appliedSize = applyTimelineCanvasSize(node, targetSize); + const restore = () => { + applyTimelineCanvasSize(node, targetSize); + markTimelineCanvasDirty(node); + }; + const scope = typeof window !== "undefined" ? window : null; + const queueTask = + typeof queueMicrotask === "function" + ? queueMicrotask + : typeof Promise !== "undefined" + ? (fn) => Promise.resolve().then(fn) + : null; + const requestFrame = + scope && typeof scope.requestAnimationFrame === "function" + ? scope.requestAnimationFrame.bind(scope) + : typeof requestAnimationFrame === "function" + ? requestAnimationFrame + : null; + const timeout = + scope && typeof scope.setTimeout === "function" + ? scope.setTimeout.bind(scope) + : typeof setTimeout === "function" + ? setTimeout + : null; + if (queueTask) queueTask(restore); + if (requestFrame) requestFrame(restore); + if (timeout) timeout(restore, 0); + return appliedSize; +} + +export function setupNode(node) { + if (!node.properties) node.properties = {}; + const savedOutputSettings = normalizeTimelineSettings(node.properties[OUTPUT_SETTINGS_PROPERTY]); + if (savedOutputSettings) { + node.etkTimelineBackendSettings = { ...savedOutputSettings, ...(node.etkTimelineBackendSettings || {}) }; + } + ensureUiInstanceId(node); + uploadMode(node); + layerEditingLocked(node); + controlsTab(node); + migrateDisableRelayWidgetOrder(node); + clarifyDefaultGuideStrengthWidget(node); + fixPromptWidgetHeights(node); + syncChainInheritedWidgets(node); + if (!node.properties.etk_ltxv_timeline_keyframes) { + node.properties.etk_ltxv_timeline_keyframes = normalizeKeyframes(getWidgetValue(node, "timeline_json", "[]"), node); + } else { + node.properties.etk_ltxv_timeline_keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + } + const selectedIndex = selectedKeyframeIndex(node, node.properties.etk_ltxv_timeline_keyframes); + if (selectedIndex < 0) { + setSelectedKeyframe(node, node.properties.etk_ltxv_timeline_keyframes.length > 0 ? 0 : -1, node.properties.etk_ltxv_timeline_keyframes); + } + + const timelineWidget = getWidget(node, "timeline_json"); + if (timelineWidget && !timelineWidget.etkTimelineHidden) { + hideWidget(timelineWidget, "etkTimelineHidden"); + } + ensureTimelineEditorWidget(node); + ensureKeyframePromptEditorWidget(node); + ensureTextLayerEditorWidget(node); + + if (!node.etkTimelineFileInput) { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/png,image/jpeg,image/webp"; + input.style.position = "fixed"; + input.style.left = "-1000px"; + input.style.top = "-1000px"; + document.body.appendChild(input); + input.addEventListener("change", async () => { + const file = input.files && input.files[0]; + input.value = ""; + if (!file) return; + try { + const keyframe = selectedKeyframe(node); + await uploadSelectedImage(node, file, keyframe ? keyframe.id : null); + } catch (error) { + setTimelineStatus(node, `Image upload failed: ${errorText(error)}`, "error"); + console.error("[ETK timeline] Image upload failed", error); + } + }); + node.etkTimelineFileInput = input; + } + installTimelinePasteFiles(node); + installTimelineDragDropHooks(node); + + for (const keyframe of node.properties.etk_ltxv_timeline_keyframes) { + preloadImage(node, keyframe); + } + syncTimelineWidget(node); +} diff --git a/web/ltxv_timeline/rendering.js b/web/ltxv_timeline/rendering.js new file mode 100644 index 0000000..5371199 --- /dev/null +++ b/web/ltxv_timeline/rendering.js @@ -0,0 +1,757 @@ +// Canvas rendering for timeline controls, layers, text, and previews. +import { + CONTROL_HEADER_HEIGHT, + CONTROL_TABS, + CONTROL_TABS_HEIGHT, + DEFAULT_TEXT_LAYER, + TIMELINE_EDITOR_HEIGHT, +} from "./constants.js"; +import { clamp } from "./runtime.js"; +import { + activeLayer, + controlsTab, + displayLatentStrength, + isEmptyLayer, + isTextLayer, + keyframeHasImageSource, + layerEditingLocked, + normalizeFilterValue, + normalizeKeyframes, + normalizePan, + normalizePrompt, + normalizePromptStrength, + normalizeTextLayerAlign, + normalizeTextLayerBold, + normalizeTextLayerColor, + normalizeTextLayerFontFamily, + normalizeTextLayerFontSize, + normalizeTextLayerItalic, + normalizeTextLayerNumber, + normalizeTextLayerString, + normalizeZoom, + selectedKeyframe, + selectedKeyframeIndex, + setSelectedKeyframe, + syncTimelineWidget, + uploadMode, + viewUrl, +} from "./model.js"; +import { + bounds, + formatSlotSeconds, + latentSlots, + outputDimension, + previewImageBox, + slotToX, +} from "./timeline_ops.js"; +import { + deleteLayerButtonBounds, + imageButtonBounds, + keyframeStrengthSliderBounds, + layerFilterSliderBounds, + lockLayerButtonBounds, + modeButtonBounds, + textLayerButtonBounds, + uploadModeButtonBounds, +} from "./interaction.js"; +export function drawDiamond(ctx, x, y, size, fill, stroke, lineWidth) { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(Math.PI / 4); + ctx.fillStyle = fill; + ctx.fillRect(-size, -size, size * 2, size * 2); + ctx.strokeStyle = stroke; + ctx.lineWidth = lineWidth; + ctx.strokeRect(-size, -size, size * 2, size * 2); + ctx.restore(); +} + +export function refreshButtonBounds(node, areaY = null, areaH = null) { + const w = 22; + const { padX, width, timelineY } = bounds(node, areaY, areaH); + return { x: padX + width - w, y: timelineY - 29, w, h: 18 }; +} + +export function drawRefreshButton(node, ctx, areaY = null, areaH = null) { + const button = refreshButtonBounds(node, areaY, areaH); + ctx.save(); + ctx.fillStyle = "#263243"; + ctx.strokeStyle = "#526174"; + ctx.lineWidth = 1; + ctx.fillRect(button.x, button.y, button.w, button.h); + ctx.strokeRect(button.x, button.y, button.w, button.h); + ctx.fillStyle = "#d7e4f3"; + ctx.font = "bold 13px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("R", button.x + button.w / 2, button.y + button.h / 2 + 0.5); + ctx.restore(); +} + +export function previewPlacement(image, boxW, boxH, fitMode, panX, panY, zoom) { + const baseScale = + fitMode === "crop" + ? Math.max(boxW / image.naturalWidth, boxH / image.naturalHeight) + : Math.min(boxW / image.naturalWidth, boxH / image.naturalHeight); + const scale = baseScale * normalizeZoom(zoom); + const drawW = image.naturalWidth * scale; + const drawH = image.naturalHeight * scale; + const excessX = Math.max(0, drawW - boxW); + const excessY = Math.max(0, drawH - boxH); + const panRangeX = Math.max(excessX, boxW); + const panRangeY = Math.max(excessY, boxH); + return { + drawW, + drawH, + drawX: (boxW - drawW) / 2 + normalizePan(panX) * (panRangeX / 2), + drawY: (boxH - drawH) / 2 + normalizePan(panY) * (panRangeY / 2), + excessX, + excessY, + panRangeX, + panRangeY, + }; +} + +export function drawCheckerboard(ctx, x, y, w, h, size = 8) { + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.clip(); + ctx.fillStyle = "#9b9b9b"; + ctx.fillRect(x, y, w, h); + ctx.fillStyle = "#c7c7c7"; + const startX = Math.floor(x / size) * size; + const startY = Math.floor(y / size) * size; + for (let yy = startY; yy < y + h; yy += size) { + for (let xx = startX; xx < x + w; xx += size) { + if (((Math.floor(xx / size) + Math.floor(yy / size)) & 1) === 0) { + ctx.fillRect(xx, yy, size, size); + } + } + } + ctx.restore(); +} + +export function editPromptModal(node, keyframeId, field, label) { + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const index = keyframes.findIndex((keyframe) => keyframe.id === keyframeId); + if (index < 0) return; + const overlay = document.createElement("div"); + overlay.style.cssText = ` + position: fixed; inset: 0; z-index: 100000; + background: rgba(0,0,0,0.48); display: flex; + align-items: center; justify-content: center; + `; + + const panel = document.createElement("div"); + panel.style.cssText = ` + width: min(720px, calc(100vw - 48px)); min-height: 320px; + background: #202733; border: 1px solid #536274; + border-radius: 8px; box-shadow: 0 18px 60px rgba(0,0,0,0.55); + padding: 14px; color: #eef4ff; font-family: sans-serif; + `; + overlay.appendChild(panel); + + const title = document.createElement("div"); + title.textContent = `${label} prompt for ${formatSlotSeconds(node, keyframes[index].slot)}`; + title.style.cssText = "font-weight: 700; font-size: 14px; margin-bottom: 10px;"; + panel.appendChild(title); + + const textarea = document.createElement("textarea"); + textarea.value = normalizePrompt(keyframes[index][field]); + textarea.style.cssText = ` + width: 100%; min-height: 220px; box-sizing: border-box; + background: #111820; color: #f3f7ff; border: 1px solid #485668; + border-radius: 6px; padding: 10px; resize: vertical; + font: 13px/1.35 sans-serif; + `; + panel.appendChild(textarea); + + const row = document.createElement("div"); + row.style.cssText = "display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px;"; + panel.appendChild(row); + + const makeButton = (text, primary = false) => { + const button = document.createElement("button"); + button.textContent = text; + button.style.cssText = ` + padding: 6px 14px; border-radius: 5px; cursor: pointer; + border: 1px solid ${primary ? "#6ba8ff" : "#536274"}; + background: ${primary ? "#2f7fd2" : "#313b49"}; + color: #ffffff; + `; + return button; + }; + const cancel = makeButton("Cancel"); + const clear = makeButton("Clear"); + const save = makeButton("Save", true); + row.appendChild(clear); + row.appendChild(cancel); + row.appendChild(save); + + const close = () => overlay.remove(); + cancel.addEventListener("click", close); + clear.addEventListener("click", () => { + textarea.value = ""; + textarea.focus(); + }); + save.addEventListener("click", () => { + const latest = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const latestIndex = latest.findIndex((keyframe) => keyframe.id === keyframeId); + if (latestIndex >= 0) { + latest[latestIndex][field] = textarea.value; + node.properties.etk_ltxv_timeline_keyframes = latest; + setSelectedKeyframe(node, latestIndex, latest); + syncTimelineWidget(node); + node.setDirtyCanvas(true, true); + } + close(); + }); + overlay.addEventListener("keydown", (event) => { + if (event.key === "Escape") close(); + if ((event.ctrlKey || event.metaKey) && event.key === "Enter") save.click(); + event.stopPropagation(); + }); + overlay.addEventListener("pointerdown", (event) => { + if (event.target === overlay) close(); + event.stopPropagation(); + }); + panel.addEventListener("pointerdown", (event) => event.stopPropagation()); + document.body.appendChild(overlay); + textarea.focus(); +} + +export function drawPreviewImage(ctx, image, boxX, boxY, boxW, boxH, fitMode, panX, panY, zoom, brightness = 1, contrast = 1) { + if (!image || !image.complete || image.naturalWidth <= 0 || image.naturalHeight <= 0) return false; + + const placement = previewPlacement(image, boxW, boxH, fitMode, panX, panY, zoom); + ctx.save(); + ctx.beginPath(); + ctx.rect(boxX, boxY, boxW, boxH); + ctx.clip(); + if ("filter" in ctx) { + ctx.filter = `brightness(${normalizeFilterValue(brightness)}) contrast(${normalizeFilterValue(contrast)})`; + } + ctx.drawImage( + image, + boxX + placement.drawX, + boxY + placement.drawY, + placement.drawW, + placement.drawH + ); + ctx.restore(); + return true; +} + +export function previewSourceForLayer(node, layer) { + if (isTextLayer(layer)) { + return { + complete: true, + naturalWidth: outputDimension(node, "width", 64), + naturalHeight: outputDimension(node, "height", 64), + }; + } + const url = viewUrl(layer && layer.image); + return url && node.etkTimelineImageCache ? node.etkTimelineImageCache.get(url) : null; +} + +export function cssTextLayerFontFamily(layer) { + const family = normalizeTextLayerFontFamily(layer && layer.fontFamily, DEFAULT_TEXT_LAYER.fontFamily); + if (/^[a-zA-Z0-9 _-]+$/.test(family)) return family; + return JSON.stringify(family); +} + +export function drawPreviewTextLayer(node, ctx, layer, boxX, boxY, boxW, boxH) { + const source = previewSourceForLayer(node, layer); + const placement = previewPlacement(source, boxW, boxH, layer.fitMode, layer.panX, layer.panY, layer.zoom); + const scale = Math.min(placement.drawW / source.naturalWidth, placement.drawH / source.naturalHeight); + const fontSize = Math.max(1, normalizeTextLayerFontSize(layer.fontSize, DEFAULT_TEXT_LAYER.fontSize) * scale); + const lines = normalizeTextLayerString(layer.text, DEFAULT_TEXT_LAYER.text).split(/\r?\n/); + const lineHeight = fontSize * 1.18; + const align = normalizeTextLayerAlign(layer.align, DEFAULT_TEXT_LAYER.align); + const textX = align === "left" + ? boxX + placement.drawX + : align === "right" + ? boxX + placement.drawX + placement.drawW + : boxX + placement.drawX + placement.drawW / 2; + const centerY = boxY + placement.drawY + placement.drawH / 2; + const startY = centerY - ((lines.length - 1) * lineHeight) / 2; + const outlineWidth = normalizeTextLayerNumber(layer.outlineWidth, DEFAULT_TEXT_LAYER.outlineWidth, 0, 64) * scale; + ctx.save(); + ctx.beginPath(); + ctx.rect(boxX, boxY, boxW, boxH); + ctx.clip(); + if ("filter" in ctx) { + ctx.filter = `brightness(${normalizeFilterValue(layer.brightness)}) contrast(${normalizeFilterValue(layer.contrast)})`; + } + ctx.font = `${normalizeTextLayerItalic(layer.italic, false) ? "italic " : ""}${normalizeTextLayerBold(layer.bold, false) ? "700 " : ""}${fontSize}px ${cssTextLayerFontFamily(layer)}`; + ctx.textAlign = align; + ctx.textBaseline = "middle"; + ctx.shadowColor = normalizeTextLayerColor(layer.shadowColor, DEFAULT_TEXT_LAYER.shadowColor); + ctx.shadowBlur = normalizeTextLayerNumber(layer.shadowBlur, DEFAULT_TEXT_LAYER.shadowBlur, 0, 128) * scale; + ctx.shadowOffsetX = normalizeTextLayerNumber(layer.shadowOffsetX, DEFAULT_TEXT_LAYER.shadowOffsetX, -512, 512) * scale; + ctx.shadowOffsetY = normalizeTextLayerNumber(layer.shadowOffsetY, DEFAULT_TEXT_LAYER.shadowOffsetY, -512, 512) * scale; + for (let index = 0; index < lines.length; index += 1) { + const y = startY + index * lineHeight; + if (outlineWidth > 0 && typeof ctx.strokeText === "function") { + ctx.lineWidth = outlineWidth; + ctx.strokeStyle = normalizeTextLayerColor(layer.outlineColor, DEFAULT_TEXT_LAYER.outlineColor); + ctx.strokeText(lines[index], textX, y); + } + ctx.fillStyle = normalizeTextLayerColor(layer.color, DEFAULT_TEXT_LAYER.color); + ctx.fillText(lines[index], textX, y); + } + ctx.restore(); + return true; +} + +export function drawPreviewLayers(node, ctx, keyframe, boxX, boxY, boxW, boxH) { + const layers = Array.isArray(keyframe.layers) ? keyframe.layers : []; + const sourceLayers = layers.length + ? layers + : keyframe.image + ? [{ id: "legacy", image: keyframe.image, fitMode: keyframe.fitMode, panX: keyframe.panX, panY: keyframe.panY, zoom: keyframe.zoom }] + : []; + drawCheckerboard(ctx, boxX, boxY, boxW, boxH); + let drawn = false; + let pending = false; + let failed = false; + for (const layer of sourceLayers) { + if (isEmptyLayer(layer)) continue; + if (isTextLayer(layer)) { + drawPreviewTextLayer(node, ctx, layer, boxX, boxY, boxW, boxH); + drawn = true; + continue; + } + const image = previewSourceForLayer(node, layer); + if (!image) { + pending = true; + continue; + } + if (image.etkTimelineLoadFailed) { + failed = true; + continue; + } + if (!drawPreviewImage(ctx, image, boxX, boxY, boxW, boxH, layer.fitMode, layer.panX, layer.panY, layer.zoom, layer.brightness, layer.contrast)) { + pending = true; + continue; + } + drawn = true; + } + return { drawn, pending, failed }; +} + +export function layerChipBounds(node, keyframe, areaY = null, areaH = null) { + const layers = Array.isArray(keyframe && keyframe.layers) ? keyframe.layers : []; + if (!layers.length) return []; + const content = controlContentBounds(node, areaY, areaH); + const chips = []; + let x = content.x; + const y = content.y + 33; + for (let index = 0; index < layers.length; index += 1) { + const empty = isEmptyLayer(layers[index]); + const text = isTextLayer(layers[index]); + const label = empty ? `E${index + 1}` : text ? `T${index + 1}` : index === 0 ? "Base" : `L${index + 1}`; + const w = empty || text ? 30 : index === 0 ? 42 : 30; + if (x + w > content.x + content.w) break; + chips.push({ x, y, w, h: 17, layerId: layers[index].id, label, index, empty }); + x += w + 5; + } + return chips; +} + +export function layerChipAt(node, keyframe, x, y) { + for (const chip of layerChipBounds(node, keyframe)) { + if (x >= chip.x && x <= chip.x + chip.w && y >= chip.y && y <= chip.y + chip.h) return chip; + } + return null; +} + +export function drawLayerLockIcon(ctx, bounds, locked) { + const cx = bounds.x + bounds.w / 2; + const bodyW = 18; + const bodyH = 11; + const bodyX = Math.round(cx - bodyW / 2); + const bodyY = bounds.y + 9; + ctx.fillRect(bodyX, bodyY, bodyW, bodyH); + ctx.strokeRect(bodyX, bodyY, bodyW, bodyH); + ctx.beginPath(); + if (locked) { + ctx.moveTo(cx - 6, bodyY); + ctx.lineTo(cx - 6, bodyY - 5); + ctx.lineTo(cx + 6, bodyY - 5); + ctx.lineTo(cx + 6, bodyY); + } else { + ctx.moveTo(cx - 6, bodyY); + ctx.lineTo(cx - 6, bodyY - 5); + ctx.lineTo(cx + 4, bodyY - 8); + ctx.lineTo(cx + 9, bodyY - 4); + } + ctx.stroke(); + ctx.fillRect(Math.round(cx - 1), bodyY + 4, 2, 4); +} + +export function drawLayerChips(node, ctx, keyframe, areaY = null, areaH = null) { + const activeId = activeLayer(keyframe) ? activeLayer(keyframe).id : null; + for (const chip of layerChipBounds(node, keyframe, areaY, areaH)) { + const selected = chip.layerId === activeId; + ctx.fillStyle = selected ? "#2f9bff" : chip.empty ? "rgba(72, 78, 90, 0.86)" : "rgba(58, 70, 88, 0.9)"; + ctx.fillRect(chip.x, chip.y, chip.w, chip.h); + ctx.strokeStyle = selected ? "#ffffff" : chip.empty ? "#7d8797" : "#526174"; + ctx.strokeRect(chip.x, chip.y, chip.w, chip.h); + ctx.fillStyle = chip.empty && !selected ? "#c5cfdd" : "#ffffff"; + ctx.font = "bold 10px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "alphabetic"; + ctx.fillText(chip.label, chip.x + chip.w / 2, chip.y + 12); + } +} + +export function drawStrengthSlider(ctx, slider, value, active) { + const normalized = clamp((value - slider.min) / (slider.max - slider.min), 0, 1); + const knobX = slider.trackX + slider.trackW * normalized; + ctx.fillStyle = "rgba(8, 12, 18, 0.78)"; + ctx.fillRect(slider.x, slider.y, slider.w, slider.h); + ctx.strokeStyle = active ? "#ffffff" : "#3f4b5d"; + ctx.strokeRect(slider.x, slider.y, slider.w, slider.h); + ctx.fillStyle = "#dce7f5"; + ctx.font = "10px sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + ctx.fillText(`${slider.label} ${value.toFixed(2)}`, slider.x + 6, slider.y + 11); + ctx.fillStyle = "#2a3444"; + ctx.fillRect(slider.trackX, slider.trackY, slider.trackW, slider.trackH); + ctx.fillStyle = slider.color; + ctx.fillRect(slider.trackX, slider.trackY, Math.max(0, knobX - slider.trackX), slider.trackH); + ctx.fillStyle = "#f4f8ff"; + ctx.fillRect(knobX - 3, slider.trackY - 3, 6, slider.trackH + 6); +} + +export function drawTimelineStatus(node, ctx, x, y, w, h) { + const status = node.etkTimelineStatus; + if (!status || !status.text) return false; + const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); + if (status.expiresAt && now > status.expiresAt) { + node.etkTimelineStatus = null; + return false; + } + const tone = status.tone || "info"; + const background = + tone === "error" + ? "rgba(116, 34, 34, 0.92)" + : tone === "success" + ? "rgba(24, 87, 54, 0.92)" + : "rgba(28, 48, 78, 0.92)"; + ctx.save(); + ctx.fillStyle = background; + ctx.fillRect(x, y, w, h); + ctx.strokeStyle = tone === "error" ? "#ff9b9b" : tone === "success" ? "#89e0a8" : "#79bdff"; + ctx.strokeRect(x, y, w, h); + ctx.fillStyle = "#ffffff"; + ctx.font = "bold 11px sans-serif"; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + const text = String(status.text).length > 150 ? `${String(status.text).slice(0, 147)}...` : String(status.text); + ctx.fillText(text, x + 8, y + h / 2); + ctx.restore(); + return true; +} + +export function controlHeaderBounds(node, areaY = null, areaH = null) { + const { padX, width, controlsY } = bounds(node, areaY, areaH); + return { x: padX + 6, y: controlsY + 6, w: width - 12, h: CONTROL_HEADER_HEIGHT }; +} + +export function controlTabBounds(node, areaY = null, areaH = null) { + const { padX, controlsY } = bounds(node, areaY, areaH); + const y = controlsY + 34; + let x = padX + 8; + return CONTROL_TABS.map((tab) => { + const bounds = { ...tab, x, y, h: CONTROL_TABS_HEIGHT }; + x += tab.w + 4; + return bounds; + }); +} + +export function controlTabAt(node, x, y, areaY = null, areaH = null) { + for (const tab of controlTabBounds(node, areaY, areaH)) { + if (x >= tab.x && x <= tab.x + tab.w && y >= tab.y && y <= tab.y + tab.h) return tab.id; + } + return null; +} + +export function controlContentBounds(node, areaY = null, areaH = null) { + const { padX, width, controlsY, controlsH } = bounds(node, areaY, areaH); + const y = controlsY + 64; + return { x: padX + 8, y, w: width - 16, h: Math.max(1, controlsY + controlsH - y - 8) }; +} + +export function drawClippedText(ctx, text, x, y, w) { + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y - 12, Math.max(1, w), 20); + ctx.clip(); + ctx.fillText(text, x, y); + ctx.restore(); +} + +export function drawControlsHeader(node, ctx, selected, targetW, targetH, selectedLayer, areaY = null, areaH = null) { + const header = controlHeaderBounds(node, areaY, areaH); + ctx.fillStyle = "#0b1119"; + ctx.fillRect(header.x, header.y, header.w, header.h); + ctx.strokeStyle = "#263243"; + ctx.strokeRect(header.x, header.y, header.w, header.h); + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + + if (!selected) { + ctx.fillStyle = "#aeb9c8"; + ctx.font = "bold 11px sans-serif"; + drawClippedText(ctx, "Keyframe controls | no keyframe selected", header.x + 8, header.y + header.h / 2 + 0.5, header.w - 16); + return; + } + + const zoom = normalizeZoom(selectedLayer ? selectedLayer.zoom : selected.zoom); + const readout = [ + `Keyframe ${formatSlotSeconds(node, selected.slot)}`, + `${targetW}x${targetH}`, + `${zoom.toFixed(2)}x`, + `latent ${displayLatentStrength(node, selected).toFixed(2)}`, + `prompt ${normalizePromptStrength(selected.promptStrength).toFixed(2)}`, + layerEditingLocked(node) ? "layers locked" : null, + selected.inputImage ? "input" : null, + ].filter(Boolean).join(" | "); + ctx.fillStyle = "#eef4ff"; + ctx.font = "bold 12px sans-serif"; + drawClippedText(ctx, readout, header.x + 8, header.y + header.h / 2 + 0.5, header.w - 16); +} + +export function drawControlsTabs(node, ctx, areaY = null, areaH = null) { + const active = controlsTab(node); + for (const tab of controlTabBounds(node, areaY, areaH)) { + const selected = tab.id === active; + ctx.fillStyle = selected ? "#2f7fd2" : "#1a2230"; + ctx.fillRect(tab.x, tab.y, tab.w, tab.h); + ctx.strokeStyle = selected ? "#79bdff" : "#3b4658"; + ctx.strokeRect(tab.x, tab.y, tab.w, tab.h); + ctx.fillStyle = selected ? "#ffffff" : "#c0cad8"; + ctx.font = "bold 10px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(tab.label, tab.x + tab.w / 2, tab.y + tab.h / 2 + 0.5); + } +} + +export function drawControlContentMessage(node, ctx, text, areaY = null, areaH = null) { + const content = controlContentBounds(node, areaY, areaH); + ctx.fillStyle = "#7d8797"; + ctx.font = "12px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(text, content.x + content.w / 2, content.y + content.h / 2); +} + +export function drawTimeline(node, ctx, areaY = null, areaH = null) { + const effectiveAreaH = Math.max( + Number.isFinite(areaH) ? areaH : TIMELINE_EDITOR_HEIGHT, + TIMELINE_EDITOR_HEIGHT + ); + const { padX, width, timelineY, timelineH, controlsY, controlsH, previewY, previewH } = bounds(node, areaY, effectiveAreaH); + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const selected = selectedKeyframe(node); + const selectedIndex = selectedKeyframeIndex(node, keyframes); + const totalSlots = latentSlots(node); + const layersLocked = layerEditingLocked(node); + + ctx.save(); + drawRefreshButton(node, ctx, areaY, effectiveAreaH); + ctx.fillStyle = "#10131a"; + ctx.fillRect(padX, timelineY, width, timelineH); + ctx.strokeStyle = "#2b3445"; + ctx.strokeRect(padX, timelineY, width, timelineH); + + ctx.strokeStyle = "#465062"; + ctx.beginPath(); + for (let i = 0; i <= 10; i += 1) { + const x = padX + width * (i / 10); + ctx.moveTo(x, timelineY + timelineH - 7); + ctx.lineTo(x, timelineY + timelineH); + } + ctx.stroke(); + + ctx.fillStyle = "#b9c7d9"; + ctx.font = "10px sans-serif"; + ctx.textAlign = "left"; + ctx.fillText(formatSlotSeconds(node, 0), padX, timelineY - 6); + ctx.textAlign = "right"; + ctx.fillText(formatSlotSeconds(node, totalSlots - 1), padX + width, timelineY - 6); + + for (let index = 0; index < keyframes.length; index += 1) { + const keyframe = keyframes[index]; + const x = slotToX(node, keyframe.slot); + const y = timelineY + timelineH / 2; + const isSelected = index === selectedIndex; + const hasImageSource = keyframeHasImageSource(keyframe); + const fill = isSelected ? "#2f9bff" : hasImageSource ? "#42c06e" : keyframe.inputImage ? "#d99b31" : "#7b8190"; + drawDiamond(ctx, x, y, 7, fill, isSelected ? "#ffffff" : "#050505", isSelected ? 2 : 1); + + ctx.fillStyle = "#d9e3f0"; + ctx.font = "9px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText(formatSlotSeconds(node, keyframe.slot), x, y + 22); + } + + ctx.fillStyle = "#101722"; + ctx.fillRect(padX, controlsY, width, controlsH); + ctx.strokeStyle = "#30394a"; + ctx.strokeRect(padX, controlsY, width, controlsH); + drawControlsHeader( + node, + ctx, + selected, + selected ? outputDimension(node, "width", 64) : 0, + selected ? outputDimension(node, "height", 64) : 0, + selected ? activeLayer(selected) : null, + areaY, + effectiveAreaH + ); + drawControlsTabs(node, ctx, areaY, effectiveAreaH); + + ctx.fillStyle = "#151821"; + ctx.fillRect(padX, previewY, width, previewH); + ctx.strokeStyle = "#30394a"; + ctx.strokeRect(padX, previewY, width, previewH); + + if (selected) { + const { targetW, targetH, boxX, boxY, boxW, boxH } = previewImageBox(node, areaY, effectiveAreaH); + const selectedLayer = activeLayer(selected); + const preview = drawPreviewLayers(node, ctx, selected, boxX, boxY, boxW, boxH); + + if (!preview.drawn) { + ctx.fillStyle = "#657084"; + ctx.font = "13px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText( + preview.failed + ? "Preview file missing - execute the node again" + : keyframeHasImageSource(selected) + ? "Loading preview..." + : selected.inputImage + ? "Connected image input" + : "Click here to upload image", + padX + width / 2, + previewY + previewH / 2 + ); + } + + ctx.strokeStyle = "#42c06e"; + ctx.strokeRect(boxX, boxY, boxW, boxH); + + const activeControlTab = controlsTab(node); + if (activeControlTab === "layers") { + const button = modeButtonBounds(node, areaY, effectiveAreaH); + const fitMode = selectedLayer ? selectedLayer.fitMode : selected.fitMode; + ctx.fillStyle = layersLocked ? "#303642" : fitMode === "crop" ? "#2f9bff" : "#9b59d0"; + ctx.fillRect(button.x, button.y, button.w, button.h); + ctx.fillStyle = layersLocked ? "#70798a" : "#ffffff"; + ctx.textAlign = "center"; + ctx.textBaseline = "alphabetic"; + ctx.font = "bold 10px sans-serif"; + ctx.fillText(fitMode.toUpperCase(), button.x + button.w / 2, button.y + 14); + + const imageButton = imageButtonBounds(node, areaY, effectiveAreaH); + ctx.fillStyle = layersLocked ? "#303642" : "#3a4658"; + ctx.fillRect(imageButton.x, imageButton.y, imageButton.w, imageButton.h); + ctx.fillStyle = layersLocked ? "#70798a" : "#ffffff"; + ctx.fillText("IMG", imageButton.x + imageButton.w / 2, imageButton.y + 14); + + const textButton = textLayerButtonBounds(node, areaY, effectiveAreaH); + ctx.fillStyle = layersLocked ? "#303642" : "#3d526a"; + ctx.fillRect(textButton.x, textButton.y, textButton.w, textButton.h); + ctx.fillStyle = layersLocked ? "#70798a" : "#ffffff"; + ctx.fillText("TXT", textButton.x + textButton.w / 2, textButton.y + 14); + + const uploadButton = uploadModeButtonBounds(node, areaY, effectiveAreaH); + const currentUploadMode = uploadMode(node); + ctx.fillStyle = layersLocked ? "#303642" : currentUploadMode === "layer" ? "#2f7fd2" : "#3a4658"; + ctx.fillRect(uploadButton.x, uploadButton.y, uploadButton.w, uploadButton.h); + ctx.fillStyle = layersLocked ? "#70798a" : "#ffffff"; + ctx.fillText(currentUploadMode.toUpperCase(), uploadButton.x + uploadButton.w / 2, uploadButton.y + 14); + + const lockLayerButton = lockLayerButtonBounds(node, areaY, effectiveAreaH); + ctx.fillStyle = layersLocked ? "#8a6d2a" : "#273343"; + ctx.fillRect(lockLayerButton.x, lockLayerButton.y, lockLayerButton.w, lockLayerButton.h); + ctx.strokeStyle = layersLocked ? "#ffd36a" : "#526174"; + ctx.strokeRect(lockLayerButton.x, lockLayerButton.y, lockLayerButton.w, lockLayerButton.h); + ctx.fillStyle = layersLocked ? "#fff0bd" : "#d7e4f3"; + drawLayerLockIcon(ctx, lockLayerButton, layersLocked); + + const deleteLayerButton = deleteLayerButtonBounds(node, areaY, effectiveAreaH); + const canDeleteLayer = !layersLocked && Array.isArray(selected.layers) && selected.layers.length > 1 && selectedLayer; + ctx.fillStyle = canDeleteLayer ? "#8f3d3d" : "#303642"; + ctx.fillRect(deleteLayerButton.x, deleteLayerButton.y, deleteLayerButton.w, deleteLayerButton.h); + ctx.fillStyle = canDeleteLayer ? "#ffffff" : "#70798a"; + ctx.fillText("DEL", deleteLayerButton.x + deleteLayerButton.w / 2, deleteLayerButton.y + 14); + if (layerChipBounds(node, selected, areaY, effectiveAreaH).length) { + drawLayerChips(node, ctx, selected, areaY, effectiveAreaH); + } + } else if (activeControlTab === "filters") { + if (selectedLayer) { + drawStrengthSlider( + ctx, + layerFilterSliderBounds(node, "brightness", areaY, effectiveAreaH), + normalizeFilterValue(selectedLayer.brightness), + node.etkSliderDragging && node.etkSliderDragging.family === "filter" && node.etkSliderDragging.kind === "brightness" + ); + drawStrengthSlider( + ctx, + layerFilterSliderBounds(node, "contrast", areaY, effectiveAreaH), + normalizeFilterValue(selectedLayer.contrast), + node.etkSliderDragging && node.etkSliderDragging.family === "filter" && node.etkSliderDragging.kind === "contrast" + ); + } else { + drawControlContentMessage(node, ctx, "No active layer to filter", areaY, effectiveAreaH); + } + } else { + drawStrengthSlider( + ctx, + keyframeStrengthSliderBounds(node, "latent", areaY, effectiveAreaH), + displayLatentStrength(node, selected), + node.etkSliderDragging && node.etkSliderDragging.family === "strength" && node.etkSliderDragging.kind === "latent" + ); + drawStrengthSlider( + ctx, + keyframeStrengthSliderBounds(node, "prompt", areaY, effectiveAreaH), + normalizePromptStrength(selected.promptStrength), + node.etkSliderDragging && node.etkSliderDragging.family === "strength" && node.etkSliderDragging.kind === "prompt" + ); + } + } else { + drawControlContentMessage(node, ctx, "Select a keyframe to edit controls", areaY, effectiveAreaH); + ctx.fillStyle = "#7d8797"; + ctx.font = "13px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "alphabetic"; + ctx.fillText("Double-click timeline to add a keyframe", padX + width / 2, previewY + previewH / 2); + } + + if (node.etkTimelineDropHover) { + ctx.fillStyle = "rgba(47, 155, 255, 0.18)"; + ctx.fillRect(padX, previewY, width, previewH); + ctx.strokeStyle = "#79bdff"; + ctx.lineWidth = 2; + ctx.strokeRect(padX + 2, previewY + 2, width - 4, previewH - 4); + ctx.fillStyle = "#ffffff"; + ctx.font = "bold 13px sans-serif"; + ctx.textAlign = "center"; + ctx.fillText(layersLocked ? "Layer editing locked" : uploadMode(node) === "layer" ? "Drop image to add layer" : "Drop image to replace active layer", padX + width / 2, previewY + previewH / 2); + } + + drawTimelineStatus(node, ctx, padX + 6, previewY + previewH - 26, width - 12, 20); + + ctx.fillStyle = "#aeb9c8"; + ctx.font = "10px sans-serif"; + ctx.textAlign = "left"; + ctx.fillText("double-click: add | D: duplicate | X: delete keyframe | lock button blocks layer edits | drag/drop/paste image | drag/arrows: pan | wheel: zoom", padX, timelineY - 22); + ctx.restore(); +} diff --git a/web/ltxv_timeline/runtime.js b/web/ltxv_timeline/runtime.js new file mode 100644 index 0000000..b43a3f5 --- /dev/null +++ b/web/ltxv_timeline/runtime.js @@ -0,0 +1,497 @@ +// ComfyUI graph lookup, widget access, backend events, and global listener lifecycle. +import { api } from "../../../../scripts/api.js"; +import { app } from "../../../../scripts/app.js"; +import { + OUTPUT_SETTINGS_PROPERTY, + TARGET_NODES, + TIMELINE_GLOBAL_LISTENER_STATE, + TIMELINE_GLOBAL_LISTENER_TOKEN, + TIMELINE_STATUS_DURATION_MS, + UI_INSTANCE_PROPERTY, + UI_INSTANCE_WIDGET, +} from "./constants.js"; +import { syncTimelineWidget } from "./model.js"; +import { setupNode } from "./node_setup.js"; +export function isTargetNode(nodeOrName) { + const name = typeof nodeOrName === "string" ? nodeOrName : nodeOrName ? nodeOrName.type : null; + return TARGET_NODES.has(name); +} + +export function disposeTimelineGlobalListenerState(state) { + for (const dispose of [...(state.disposers || [])].reverse()) { + try { + dispose(); + } catch (error) { + console.warn("[ETK timeline] Failed to remove stale global listener", error); + } + } + state.installed = new Set(); + state.disposers = []; +} + +export function timelineGlobalListenerState() { + let state = window[TIMELINE_GLOBAL_LISTENER_STATE]; + if (!state || typeof state !== "object") { + state = { token: null, installed: new Set(), disposers: [] }; + window[TIMELINE_GLOBAL_LISTENER_STATE] = state; + } + if (!(state.installed instanceof Set)) state.installed = new Set(state.installed || []); + if (!Array.isArray(state.disposers)) state.disposers = []; + if (state.token && state.token !== TIMELINE_GLOBAL_LISTENER_TOKEN) { + disposeTimelineGlobalListenerState(state); + } + state.token = TIMELINE_GLOBAL_LISTENER_TOKEN; + return state; +} + +export function resetTimelineGlobalListeners() { + const state = timelineGlobalListenerState(); + disposeTimelineGlobalListenerState(state); + state.token = TIMELINE_GLOBAL_LISTENER_TOKEN; +} + +export function addTimelineWindowListener(key, eventName, handler, options = false) { + const state = timelineGlobalListenerState(); + if (state.installed.has(key)) return; + let effectiveOptions = options; + let controller = null; + if (typeof AbortController !== "undefined") { + controller = new AbortController(); + effectiveOptions = typeof options === "boolean" ? { capture: options, signal: controller.signal } : { ...(options || {}), signal: controller.signal }; + } + window.addEventListener(eventName, handler, effectiveOptions); + state.installed.add(key); + state.disposers.push(() => { + if (controller) { + controller.abort(); + } else if (typeof window.removeEventListener === "function") { + window.removeEventListener(eventName, handler, effectiveOptions); + } + state.installed.delete(key); + }); +} + +export function errorText(error) { + if (!error) return "unknown error"; + return error.message ? String(error.message) : String(error); +} + +export function setTimelineStatus(node, text, tone = "info", durationMs = TIMELINE_STATUS_DURATION_MS) { + if (!node) return; + const now = typeof performance !== "undefined" && performance.now ? performance.now() : Date.now(); + node.etkTimelineStatus = { text: String(text || ""), tone, expiresAt: durationMs > 0 ? now + durationMs : 0 }; + if (typeof node.setDirtyCanvas === "function") node.setDirtyCanvas(true, true); + if (node.graph && typeof node.graph.setDirtyCanvas === "function") node.graph.setDirtyCanvas(true, true); +} + +export function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +export function makeId() { + if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID(); + return `etk-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +export function getWidget(node, name) { + return node.widgets ? node.widgets.find((widget) => widget.name === name) : undefined; +} + +export function getWidgetValue(node, name, fallback) { + const widget = getWidget(node, name); + return widget && widget.value != null ? widget.value : fallback; +} + +export function getInput(node, name) { + return node.inputs ? node.inputs.find((input) => input.name === name) : undefined; +} + +export function graphLinkById(graph, linkId) { + if (!graph || linkId == null) return null; + const candidates = [linkId, Number(linkId), String(linkId)]; + for (const store of [graph.links, graph._links]) { + if (!store) continue; + if (typeof store.get === "function") { + for (const candidate of candidates) { + const link = store.get(candidate); + if (link) return link; + } + } else { + for (const candidate of candidates) { + const link = store[candidate]; + if (link) return link; + } + } + } + return null; +} + +export function valueFromCandidate(value) { + if (value == null) return undefined; + if (Array.isArray(value)) return value.length ? value[0] : undefined; + if (typeof value === "object") { + if ("value" in value) return value.value; + if ("text" in value) return value.text; + } + return value; +} + +export function linkedSourceInfo(node, name) { + const input = getInput(node, name); + const linkId = input ? input.link : null; + const graph = node.graph || app.graph; + if (linkId == null || !graph) return null; + const link = graphLinkById(graph, linkId); + if (!link) return null; + const originId = link.origin_id ?? link.originId; + const originSlot = link.origin_slot ?? link.originSlot ?? 0; + const sourceNode = + typeof graph.getNodeById === "function" + ? graph.getNodeById(originId) + : graph._nodes + ? graph._nodes.find((candidate) => candidate.id === originId) + : null; + if (!sourceNode) return null; + const output = sourceNode.outputs ? sourceNode.outputs[originSlot] : null; + return { link, sourceNode, output }; +} + +export function linkedSourceNode(node, name) { + const info = linkedSourceInfo(node, name); + return info ? info.sourceNode : null; +} + +export function valueFromLinkedSource(info, preferredName) { + if (!info || !info.sourceNode) return undefined; + const sourceNode = info.sourceNode; + const widgetNames = [preferredName]; + if (info.output && info.output.name) widgetNames.push(info.output.name); + const lowerNames = widgetNames.map((name) => String(name || "").toLowerCase()).filter(Boolean); + const widgets = sourceNode.widgets || []; + const namedWidget = widgets.find((widget) => lowerNames.includes((widget.name || "").toLowerCase())); + if (namedWidget && namedWidget.value != null) return namedWidget.value; + if (widgets.length === 1 && widgets[0].value != null) return widgets[0].value; + + if (sourceNode.properties) { + let propertyValue = sourceNode.properties[preferredName]; + const candidate = valueFromCandidate(propertyValue); + if (candidate != null) return candidate; + } + + return undefined; +} + +export function getNumericControlValue(node, name, fallback, options = {}) { + const backendValue = node.etkTimelineBackendSettings ? node.etkTimelineBackendSettings[name] : undefined; + const backendNumeric = Number(valueFromCandidate(backendValue)); + if ( + Number.isFinite(backendNumeric) && + (options.min == null || backendNumeric >= options.min) && + (!options.rejectZero || backendNumeric !== 0) + ) { + return backendNumeric; + } + + const sourceValue = valueFromLinkedSource(linkedSourceInfo(node, name), name); + const widgetValue = getWidgetValue(node, name, fallback); + const candidates = sourceValue != null ? [sourceValue, widgetValue, fallback] : [widgetValue, fallback]; + for (const candidate of candidates) { + const numeric = Number(valueFromCandidate(candidate)); + if (!Number.isFinite(numeric)) continue; + if (options.min != null && numeric < options.min) continue; + if (options.rejectZero && numeric === 0) continue; + return numeric; + } + return fallback; +} + +export function setWidgetValue(node, name, value, notify = false) { + const widget = getWidget(node, name); + if (!widget) return; + widget.value = value; + if (notify && typeof widget.callback === "function") widget.callback(value, node, widget); +} + +export function hideWidget(widget, flagName, options = {}) { + if (!widget || widget[flagName]) return; + widget[flagName] = true; + widget.hidden = true; + if (options.serialize === false) widget.serialize = false; + widget.computeSize = () => [0, 0]; + widget.draw = () => {}; + if (widget.element) widget.element.style.display = "none"; +} + +export function ensureUiInstanceId(node) { + if (!node.properties) node.properties = {}; + let value = node.etkTimelineUiInstanceId; + if (!value) { + value = makeId(); + node.etkTimelineUiInstanceId = value; + } + node.properties[UI_INSTANCE_PROPERTY] = value; + const widget = getWidget(node, UI_INSTANCE_WIDGET); + if (widget) { + widget.value = value; + if (widget.element) widget.element.value = value; + hideWidget(widget, "etkTimelineUiInstanceHidden", { serialize: false }); + } + return value; +} + +export function uiPayloadValue(message, name) { + if (!message) return undefined; + const source = message.output && message.output[name] != null ? message.output : message; + const value = source[name]; + return Array.isArray(value) ? value[0] : value; +} + +export function parsePayloadObject(payload) { + if (payload == null) return null; + if (Array.isArray(payload)) payload = payload.length ? payload[0] : null; + if (typeof payload === "string") { + try { + payload = JSON.parse(payload); + } catch { + return null; + } + } + return payload && typeof payload === "object" ? payload : null; +} + +export function payloadUiInstanceId(payload) { + const parsed = parsePayloadObject(payload); + return parsed && parsed.ui_instance_id != null ? String(parsed.ui_instance_id) : ""; +} + +export function payloadsMatchNode(node, payloads) { + const nodeId = ensureUiInstanceId(node); + return payloads.some((payload) => { + const payloadId = payloadUiInstanceId(payload); + return !!payloadId && payloadId === nodeId; + }); +} + +export function normalizeTimelineSettings(payload) { + if (payload == null) return null; + if (Array.isArray(payload)) payload = payload.length ? payload[0] : null; + if (typeof payload === "string") { + try { + payload = JSON.parse(payload); + } catch { + return null; + } + } + if (!payload || typeof payload !== "object") return null; + const settings = {}; + for (const name of ["width", "height", "length", "fps"]) { + const numeric = Number(valueFromCandidate(payload[name])); + if (Number.isFinite(numeric)) settings[name] = name === "fps" ? Math.max(0.001, numeric) : Math.round(numeric); + } + return Object.keys(settings).length ? settings : null; +} + +export function applyBackendTimelineSettings(node, payload) { + const settings = normalizeTimelineSettings(payload); + if (!settings) return false; + const previousSettings = node.etkTimelineBackendSettings || {}; + const nextSettings = { ...previousSettings, ...settings }; + node.etkTimelineBackendSettings = nextSettings; + if (!node.properties) node.properties = {}; + node.properties[OUTPUT_SETTINGS_PROPERTY] = nextSettings; + let changed = false; + for (const name of ["width", "height", "length", "fps"]) { + if (settings[name] == null) continue; + if (previousSettings[name] !== settings[name]) changed = true; + setWidgetValue(node, name, settings[name], true); + } + return changed; +} + +export function graphNodes(graph) { + return graph ? graph.nodes || graph._nodes || [] : []; +} + +export function rootGraph() { + return app.rootGraph || (app.graph && app.graph.rootGraph) || app.graph || null; +} + +export function activeCanvasGraph() { + const canvas = app.canvas; + if (canvas && typeof canvas.getCurrentGraph === "function") { + return canvas.getCurrentGraph() || canvas.graph || app.graph || null; + } + return (canvas && canvas.graph) || app.graph || null; +} + +export function uniqueGraphs(...graphs) { + const seen = new Set(); + const result = []; + for (const graph of graphs) { + if (!graph || seen.has(graph)) continue; + seen.add(graph); + result.push(graph); + } + return result; +} + +export function findNodeInGraphByLocalId(graph, nodeId) { + if (nodeId == null || !graph) return null; + const normalizedId = String(nodeId); + const numericId = Number(nodeId); + if (typeof graph.getNodeById === "function" && Number.isFinite(numericId)) { + const node = graph.getNodeById(numericId); + if (node) return node; + } + return graphNodes(graph).find((node) => String(node.id) === normalizedId) || null; +} + +export function walkGraphs(graph, callback, visited = new Set()) { + if (!graph || visited.has(graph)) return null; + visited.add(graph); + + for (const node of graphNodes(graph)) { + const result = callback(node, graph); + if (result) return result; + if (node.subgraph) { + const subgraphResult = walkGraphs(node.subgraph, callback, visited); + if (subgraphResult) return subgraphResult; + } + } + + const subgraphs = graph.subgraphs; + if (subgraphs && typeof subgraphs.values === "function") { + for (const subgraph of subgraphs.values()) { + const subgraphResult = walkGraphs(subgraph, callback, visited); + if (subgraphResult) return subgraphResult; + } + } else if (Array.isArray(subgraphs)) { + for (const subgraph of subgraphs) { + const subgraphResult = walkGraphs(subgraph, callback, visited); + if (subgraphResult) return subgraphResult; + } + } + + return null; +} + +export function findTimelineNodeByLocalId(nodeId, preferredGraph = null) { + const normalizedId = String(nodeId); + for (const graph of uniqueGraphs(preferredGraph, activeCanvasGraph(), rootGraph())) { + const node = findNodeInGraphByLocalId(graph, normalizedId); + if (isTargetNode(node)) return node; + } + return walkGraphs(rootGraph(), (node) => { + return String(node.id) === normalizedId && isTargetNode(node) ? node : null; + }); +} + +export function findTimelineNodeByLocatorId(locatorId) { + if (locatorId == null) return null; + const parts = String(locatorId).split(":"); + if (parts.length !== 2 || Number.isFinite(Number(parts[0]))) return null; + const [graphId, localId] = parts; + return walkGraphs(rootGraph(), (node, graph) => { + return String(graph && graph.id) === graphId && String(node.id) === localId && isTargetNode(node) ? node : null; + }); +} + +export function findTimelineNodeByExecutionId(executionId) { + if (executionId == null) return null; + const normalizedId = String(executionId); + const parts = normalizedId.split(":").filter((part) => part !== ""); + if (parts.length <= 1) return findTimelineNodeByLocalId(normalizedId); + + for (const graph of uniqueGraphs(rootGraph(), activeCanvasGraph())) { + let currentGraph = graph; + let node = null; + for (let index = 0; index < parts.length; index += 1) { + node = findNodeInGraphByLocalId(currentGraph, parts[index]); + if (!node) break; + if (index === parts.length - 1) { + if (isTargetNode(node)) return node; + break; + } + currentGraph = node.subgraph; + if (!currentGraph) break; + } + } + + return findTimelineNodeByLocalId(parts[parts.length - 1]); +} + +export function timelinePayloadUiInstanceId(payload) { + if (payload == null) return null; + if (Array.isArray(payload)) payload = payload.length ? payload[0] : null; + if (typeof payload === "string") { + try { + payload = JSON.parse(payload); + } catch { + return null; + } + } + if (!payload || typeof payload !== "object") return null; + const value = valueFromCandidate(payload[UI_INSTANCE_WIDGET] || payload.uiInstanceId); + return value == null ? null : String(value); +} + +export function nodeUiInstanceId(node) { + if (!node) return null; + const propertyValue = node.properties ? node.properties[UI_INSTANCE_PROPERTY] : null; + if (propertyValue != null) return String(propertyValue); + const widget = getWidget(node, UI_INSTANCE_WIDGET); + return widget && widget.value != null ? String(widget.value) : null; +} + +export function findTimelineNodeByUiInstanceId(settingsPayload) { + const uiInstanceId = timelinePayloadUiInstanceId(settingsPayload); + if (!uiInstanceId) return null; + return walkGraphs(rootGraph(), (node) => { + return isTargetNode(node) && nodeUiInstanceId(node) === uiInstanceId ? node : null; + }); +} + +export function findTimelineNodeById(nodeId) { + if (nodeId == null) return null; + return findTimelineNodeByLocatorId(nodeId) || findTimelineNodeByExecutionId(nodeId); +} + +export function applyBackendTimelineSettingsToNode(node, settingsPayload) { + if (!node || !isTargetNode(node)) return false; + if (!payloadsMatchNode(node, [settingsPayload])) return false; + setupNode(node); + const changed = applyBackendTimelineSettings(node, settingsPayload); + syncTimelineWidget(node); + node.setDirtyCanvas(true, true); + if (node.graph && typeof node.graph.setDirtyCanvas === "function") { + node.graph.setDirtyCanvas(true, true); + } + return changed; +} + +export function deliverBackendTimelineSettingsToNode(node, settingsPayload) { + if (!node || !isTargetNode(node)) return false; + if (!payloadsMatchNode(node, [settingsPayload])) return false; + applyBackendTimelineSettingsToNode(node, settingsPayload); + return true; +} + +export function installBackendTimelineSettingsListener() { + if (installBackendTimelineSettingsListener.installed) return; + installBackendTimelineSettingsListener.installed = true; + if (!api || typeof api.addEventListener !== "function") return; + api.addEventListener("etk_ltxv_timeline_settings", (event) => { + const detail = event && event.detail ? event.detail : {}; + const candidates = [ + findTimelineNodeById(detail.display_node), + findTimelineNodeById(detail.node), + findTimelineNodeByUiInstanceId(detail.settings), + ]; + const seen = new Set(); + for (const node of candidates) { + if (!node || seen.has(node)) continue; + seen.add(node); + if (deliverBackendTimelineSettingsToNode(node, detail.settings)) return; + } + }); +} diff --git a/web/ltxv_timeline/timeline_ops.js b/web/ltxv_timeline/timeline_ops.js new file mode 100644 index 0000000..ef122c7 --- /dev/null +++ b/web/ltxv_timeline/timeline_ops.js @@ -0,0 +1,300 @@ +// Timeline geometry, keyframe operations, sliders, and time conversion. +import { + CUSTOM_AREA_TOP, + PREVIEW_MIN_HEIGHT, + TIMELINE_BOTTOM_PAD, + TIMELINE_CONTROLS_GAP, + TIMELINE_CONTROLS_HEIGHT, + TIMELINE_GAP, + TIMELINE_TOP_PAD, + TIMELINE_TRACK_HEIGHT, +} from "./constants.js"; +import { + clamp, + getNumericControlValue, + getWidgetValue, + linkedSourceNode, + makeId, + normalizeTimelineSettings, + setTimelineStatus, + setWidgetValue, +} from "./runtime.js"; +import { + displayLatentStrength, + mirrorBaseLayer, + normalizeFilterValue, + normalizeKeyframes, + normalizeLatentStrengthValue, + normalizeLayer, + normalizePromptStrength, + normalizeSlot, + preloadImage, + rejectLayerEditIfLocked, + setSelectedKeyframe, + syncTimelineWidget, +} from "./model.js"; +import { timelineEditorWidget } from "./editors.js"; +import { keyframeStrengthSliderBounds, layerFilterSliderBounds } from "./interaction.js"; +export function bounds(node, areaY = null, areaH = null) { + const padX = 20; + const width = Math.max(1, node.size[0] - padX * 2); + const widget = timelineEditorWidget(node); + const widgetY = Number.isFinite(areaY) + ? areaY + : Number.isFinite(widget && widget.last_y) + ? widget.last_y + : Number.isFinite(node.etkTimelineEditorY) + ? node.etkTimelineEditorY + : CUSTOM_AREA_TOP; + const timelineY = widgetY + TIMELINE_TOP_PAD; + const timelineH = TIMELINE_TRACK_HEIGHT; + const controlsY = timelineY + timelineH + TIMELINE_GAP; + const controlsH = TIMELINE_CONTROLS_HEIGHT; + const previewY = controlsY + controlsH + TIMELINE_CONTROLS_GAP; + const availableHeight = Number.isFinite(areaH) + ? areaH - TIMELINE_TOP_PAD - timelineH - TIMELINE_GAP - controlsH - TIMELINE_CONTROLS_GAP - TIMELINE_BOTTOM_PAD + : node.size[1] - previewY - TIMELINE_BOTTOM_PAD; + const previewH = Math.max(PREVIEW_MIN_HEIGHT, availableHeight); + return { padX, width, widgetY, timelineY, timelineH, controlsY, controlsH, previewY, previewH }; +} + +export function duration(node) { + const rawLength = Math.max(1, Math.round(getNumericControlValue(node, "length", 97, { min: 1, rejectZero: true }))); + const normalized = Math.max(1, Math.round((rawLength - 1) / 8) * 8 + 1); + if (!linkedSourceNode(node, "length") && normalized !== rawLength) setWidgetValue(node, "length", normalized); + return normalized; +} + +export function latentSlots(node) { + return Math.max(1, Math.floor((duration(node) - 1) / 8) + 1); +} + +export function timelineFps(node) { + if (!node) return 25; + const fps = Number(getNumericControlValue(node, "fps", Number.NaN, { min: 0.001 })); + if (Number.isFinite(fps) && fps > 0) return fps; + const promptFps = Number(getNumericControlValue(node, "prompt_schedule_fps", 25, { min: 0.001 })); + return Number.isFinite(promptFps) && promptFps > 0 ? promptFps : 25; +} + +export function slotToSeconds(node, slot) { + return Math.max(0, normalizeSlot({ slot }) * 8) / timelineFps(node); +} + +export function formatSeconds(value) { + const seconds = Number(value); + if (!Number.isFinite(seconds)) return "0s"; + const fixed = seconds >= 10 ? seconds.toFixed(1) : seconds.toFixed(2); + return `${fixed.replace(/\.?0+$/, "")}s`; +} + +export function formatSlotSeconds(node, slot) { + return formatSeconds(slotToSeconds(node, slot)); +} + +export function outputDimension(node, name, fallback) { + const raw = Math.max(32, getNumericControlValue(node, name, fallback, { min: 32 })); + return Math.max(32, Math.round(raw / 32) * 32); +} + +export function widgetOutputSettings(node) { + if (!node) return null; + return normalizeTimelineSettings({ + width: getWidgetValue(node, "width"), + height: getWidgetValue(node, "height"), + length: getWidgetValue(node, "length"), + fps: getWidgetValue(node, "fps"), + }); +} + +export function previewImageBox(node, areaY = null, areaH = null) { + const { padX, width, previewY, previewH } = bounds(node, areaY, areaH); + const targetW = outputDimension(node, "width", 64); + const targetH = outputDimension(node, "height", 64); + const scale = Math.min((width - 12) / targetW, (previewH - 12) / targetH); + const boxW = Math.max(1, targetW * scale); + const boxH = Math.max(1, targetH * scale); + return { + targetW, + targetH, + boxW, + boxH, + boxX: padX + (width - boxW) / 2, + boxY: previewY + 6 + Math.max(0, (previewH - 12 - boxH) / 2), + }; +} + +export function slotToX(node, slot) { + const { padX, width } = bounds(node); + const maxSlot = Math.max(1, latentSlots(node) - 1); + return padX + width * clamp(slot / maxSlot, 0, 1); +} + +export function xToSlot(node, x) { + const { padX, width } = bounds(node); + const maxSlot = Math.max(0, latentSlots(node) - 1); + return Math.round(clamp((x - padX) / width, 0, 1) * maxSlot); +} + +export function findKeyframeAt(node, pos) { + const { timelineY, timelineH } = bounds(node); + const [x, y] = pos; + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + for (let index = keyframes.length - 1; index >= 0; index -= 1) { + const cx = slotToX(node, keyframes[index].slot); + const cy = timelineY + timelineH / 2; + if (Math.abs(x - cx) <= 16 && Math.abs(y - cy) <= 18) return index; + } + return -1; +} + +export function slotConflict(node, slot, ignoreIndex = -1) { + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + return keyframes.findIndex((keyframe, index) => index !== ignoreIndex && keyframe.slot === slot); +} + +export function nearestFreeSlot(node, slot) { + const maxSlot = latentSlots(node) - 1; + const desiredSlot = clamp(slot, 0, maxSlot); + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const occupied = new Set(keyframes.map((keyframe) => keyframe.slot)); + if (!occupied.has(desiredSlot)) return desiredSlot; + + for (let offset = 1; offset <= maxSlot; offset += 1) { + const right = desiredSlot + offset; + if (right <= maxSlot && !occupied.has(right)) return right; + const left = desiredSlot - offset; + if (left >= 0 && !occupied.has(left)) return left; + } + + return null; +} + +export function addKeyframeAt(node, x) { + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const requestedSlot = xToSlot(node, x); + const slot = nearestFreeSlot(node, requestedSlot); + if (slot == null) { + const conflictIndex = slotConflict(node, requestedSlot); + if (conflictIndex >= 0) setSelectedKeyframe(node, conflictIndex, keyframes); + setTimelineStatus(node, "No free keyframe positions are available for another keyframe.", "error"); + node.setDirtyCanvas(true, true); + return false; + } + const conflictIndex = slotConflict(node, slot); + if (conflictIndex >= 0) { + setSelectedKeyframe(node, conflictIndex, keyframes); + node.setDirtyCanvas(true, true); + return false; + } + + const keyframe = { id: makeId(), time: slotToSeconds(node, slot), slot, fitMode: "crop", panX: 0, panY: 0, zoom: 1, image: null }; + keyframes.push(keyframe); + keyframes.sort((a, b) => a.slot - b.slot); + node.properties.etk_ltxv_timeline_keyframes = keyframes; + setSelectedKeyframe(node, keyframes.findIndex((candidate) => candidate.id === keyframe.id), keyframes); + syncTimelineWidget(node); + node.setDirtyCanvas(true, true); + return true; +} + +export function duplicateKeyframeAtIndex(node, index) { + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + if (index < 0 || index >= keyframes.length) return false; + const source = keyframes[index]; + const slot = nearestFreeSlot(node, source.slot + 1); + if (slot == null) { + setTimelineStatus(node, "No free keyframe positions are available for a duplicate keyframe.", "error"); + node.setDirtyCanvas(true, true); + return false; + } + + const duplicate = { + ...source, + id: makeId(), + time: slotToSeconds(node, slot), + slot, + image: source.image ? { ...source.image } : null, + layers: Array.isArray(source.layers) + ? source.layers.map((layer) => ({ ...layer, image: layer.image ? { ...layer.image } : null })) + : [], + }; + keyframes.push(duplicate); + keyframes.sort((a, b) => a.slot - b.slot); + node.properties.etk_ltxv_timeline_keyframes = keyframes; + setSelectedKeyframe(node, keyframes.findIndex((candidate) => candidate.id === duplicate.id), keyframes); + preloadImage(node, duplicate); + syncTimelineWidget(node); + setTimelineStatus(node, `Duplicated keyframe to ${formatSlotSeconds(node, slot)}.`, "success"); + node.setDirtyCanvas(true, true); + return true; +} + +export function deleteKeyframeAtIndex(node, index) { + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + if (index < 0 || index >= keyframes.length) return false; + keyframes.splice(index, 1); + node.properties.etk_ltxv_timeline_keyframes = keyframes; + setSelectedKeyframe(node, keyframes.length ? Math.min(index, keyframes.length - 1) : -1, keyframes); + syncTimelineWidget(node); + setTimelineStatus(node, "Deleted selected keyframe.", "success"); + node.setDirtyCanvas(true, true); + return true; +} + +export function setKeyframeStrength(node, keyframeId, kind, rawValue) { + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const index = keyframes.findIndex((keyframe) => keyframe.id === keyframeId); + if (index < 0) return false; + const next = { ...keyframes[index] }; + if (kind === "latent") { + next.latentStrength = normalizeLatentStrengthValue(rawValue, displayLatentStrength(node, next)); + } else if (kind === "prompt") { + next.promptStrength = normalizePromptStrength(rawValue); + } else { + return false; + } + keyframes[index] = next; + node.properties.etk_ltxv_timeline_keyframes = keyframes; + setSelectedKeyframe(node, index, keyframes); + syncTimelineWidget(node); + node.setDirtyCanvas(true, true); + return true; +} + +export function setLayerFilter(node, keyframeId, layerId, kind, rawValue) { + if (rejectLayerEditIfLocked(node, "Layer filter")) return false; + const keyframes = normalizeKeyframes(node.properties.etk_ltxv_timeline_keyframes, node); + const keyframeIndex = keyframes.findIndex((keyframe) => keyframe.id === keyframeId); + if (keyframeIndex < 0) return false; + const keyframe = keyframes[keyframeIndex]; + const layerIndex = Array.isArray(keyframe.layers) ? keyframe.layers.findIndex((layer) => layer.id === layerId) : -1; + if (layerIndex < 0) return false; + const nextLayer = { + ...keyframe.layers[layerIndex], + [kind]: normalizeFilterValue(rawValue), + }; + keyframe.layers[layerIndex] = normalizeLayer(nextLayer, { id: nextLayer.id }) || nextLayer; + keyframe.selectedLayerId = keyframe.layers[layerIndex].id; + mirrorBaseLayer(keyframe); + keyframes[keyframeIndex] = keyframe; + node.properties.etk_ltxv_timeline_keyframes = keyframes; + setSelectedKeyframe(node, keyframeIndex, keyframes); + syncTimelineWidget(node); + node.setDirtyCanvas(true, true); + return true; +} + +export function sliderValueFromX(slider, x) { + const ratio = clamp((x - slider.trackX) / slider.trackW, 0, 1); + return slider.min + ratio * (slider.max - slider.min); +} + +export function updateTimelineSliderDrag(node, pos) { + if (!node || !node.etkSliderDragging || !pos) return false; + const drag = node.etkSliderDragging; + const slider = drag.family === "filter" ? layerFilterSliderBounds(node, drag.kind) : keyframeStrengthSliderBounds(node, drag.kind); + const value = sliderValueFromX(slider, pos[0]); + if (drag.family === "filter") return setLayerFilter(node, drag.keyframeId, drag.layerId, drag.kind, value); + return setKeyframeStrength(node, drag.keyframeId, drag.kind, value); +}