Compare commits

..
5 Commits
41 changed files with 1407 additions and 130 deletions
-3
View File
@@ -1,6 +1,3 @@
.github/
preview/
tests/
registry/
VERIFICATION.md
ROLLBACK.md
+17
View File
@@ -1,5 +1,22 @@
# Changelog
## 2.1.1 · 2026-11-12
- Remove the duplicate lower Fit button in the standalone workspace; the top Fit control and the workflow comparison node's button remain available.
- Move the whole-image and masked-area choices directly below Generate in the workspace.
- Place color-paint controls in the existing lower toolbar for both editing modes.
## 2.1.0 · 2026-11-12
- Add an independent color-paint layer to Mask and Crop & Blend modes. Alt-click samples the visible image color, Ctrl + wheel changes brush size, and the painted image is used for inpainting.
- Show upload progress while merging an adopted crop before releasing the selection; keep the selection when the upload fails so it can be retried.
- Keep the editor, comparison and parameter controls in the workspace when ComfyUI remounts DOM widgets during layout changes.
- Show download and upload progress while adopting results; retry transient image-read failures after uploads and preserve the previous source on failure.
- Add wheel zoom, middle-button pan and Fit to the standalone workspace's sliding and before/after comparison views.
- Move Generate, width, height and denoise controls higher in the workspace settings, and allow positive and negative prompts to be collapsed.
- Adopt GPL-3.0-only for the current project and preserve the original MIT copyright and permission notice for earlier releases and applicable legacy material. Previous releases retain the permissions under which they were distributed.
- Update bilingual documentation with real workspace screenshots, a tested-model compatibility matrix, workflow limits and a concise release history.
## 2.0.1 · stability fixes (2026-11-10)
- Release late preview object URLs after sibling failures, discard corrupt cached PNGs, bound decoding and cancel stale preview reads. Downloads and both ordinary/crop adoption use bounded transfers too.
+670 -17
View File
@@ -1,21 +1,674 @@
MIT License
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (c) 2026 Adachi Workbench contributors
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
Preamble
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Adachi Workbench contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5
View File
@@ -0,0 +1,5 @@
# License history
The current project is licensed under GPL-3.0-only; see the root [LICENSE](../LICENSE).
Releases up to and including v2.0.1 were distributed under the MIT License. Their original copyright and permission notice is reproduced in [MIT-previous-releases.txt](MIT-previous-releases.txt), including for previously MIT-licensed material carried forward where applicable. Recipients of older versions retain the permissions granted with those versions. The retained notice does not grant an additional MIT license to new GPL-only changes.
+60 -5
View File
@@ -8,7 +8,7 @@ An inline inpainting workspace for ComfyUI, with WebUI-style controls and tools
**English** · [简体中文](README.zh-CN.md)
[![License: MIT](https://img.shields.io/badge/License-MIT-38966b)](LICENSE)
[![License: GPL-3.0-only](https://img.shields.io/badge/License-GPL--3.0--only-38966b)](LICENSE)
[![ComfyUI custom nodes](https://img.shields.io/badge/ComfyUI-Custom_Nodes-4776b4)](https://github.com/Comfy-Org/ComfyUI)
[![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-4776b4)](pyproject.toml)
[![Validate plugin](https://github.com/adachi0130/Adachi-Inpaint-Workbench/actions/workflows/ci.yml/badge.svg)](https://github.com/adachi0130/Adachi-Inpaint-Workbench/actions/workflows/ci.yml)
@@ -37,6 +37,17 @@ English and Simplified Chinese interfaces follow the ComfyUI language setting. T
## Features
<details open>
<summary>Workbench screenshots</summary>
![Light workbench with an inline mask and workflow settings](preview/workbench-light.png)
![Dark workbench with the comparison divider](preview/workbench-compare-dark.png)
*Screenshots captured from the running plugin using a synthetic illustration. The comparison result is a CPU color inversion to demonstrate the divider, not an AI-generated inpaint.*
</details>
### Adachi Workbench · light and dark themes
Click **Open Adachi Workbench** on an Adachi Inpaint Workbench node. Workflows need the plugin’s Inline Mask, inpaint and Image Compare nodes; their IDs, titles, preset names and model filenames can be different. The workspace follows the selected node’s actual connections to its editor and result. If several candidates are ambiguous, choose them under **Workflow connections**. An enabled Group Bypass Presets node supplies its own preset buttons; missing or ambiguous controllers show a reminder.
@@ -47,8 +58,12 @@ New profiles inherit the workflow’s LoRA chains, including different positive/
Drag the divider between the image and settings to resize them. The width is remembered; double-click resets it, and arrow keys make small adjustments. Narrow screens keep the stacked layout. The theme icon remembers the light/dark preference. Hover controls for help.
The Generate button, width, height and denoise controls are near the top of the settings panel. Collapse the positive and negative prompt sections when you need more room.
Generation shows actual workflow stages and sampler steps above settings. You can keep painting, zooming and changing parameters; the queued task uses its submitted snapshot. Generated results open sliding comparison without replacing the source. Use the single before/after icon to toggle images, the pencil to edit, and **Adopt** to adopt. Crop adoption returns to editing with its region and mask intact. Source history can restore previous images, masks and crop state for the loaded browser session.
In the standalone workspace's sliding and before/after comparison views, scroll over the image to zoom, drag with the middle mouse button to pan, and use **Fit** to restore the fitted view. The workflow's Image Compare node continues to use the wheel for navigating the workflow canvas.
The comparison node and workspace recover lost execution notifications by queued task ID. Image loading reports actual received bytes, a percentage when size is known, then decoding; transient failures retry and can resume after reconnecting. Expired server-side previews require another run. Recovery does not create preview files. Updating backend code requires a full ComfyUI restart; frontend-only updates need a hard refresh.
The [official workflow](examples/Adachi-Workbench.json) is a starting point, not a required template. Validation also covers independently built multi-pipeline workflows, changed node IDs and renamed presets. Labels default to English; choose Simplified Chinese in ComfyUI settings for the Chinese interface. Other locales use the English fallback.
@@ -57,10 +72,14 @@ The [official workflow](examples/Adachi-Workbench.json) is a starting point, not
Upload, drop or paste a source image and paint on **Inline Mask** itself. In Mask mode, uploading an image automatically fills the connected inpaint dimensions; a replacement of the same size keeps the mask, while a different-sized image starts with a fresh mask. Mask strokes stay in the browser and are saved with the workflow; painting does not require a disk save/load round-trip for every stroke.
Use the **✎ color paint** button at the lower left to paint directly on the image in either mode. Choose a color or **Alt + click** the visible image to sample one; **Ctrl + wheel** changes brush size. Color painting uses a separate layer with its own undo and redo, and the painted image becomes the source for subsequent inpainting. Click the button again to return to mask editing.
If browser draft storage is too small, the color layer may be saved as a PNG in ComfyUI's input directory instead of embedded in the workflow. Include that referenced file when moving the workflow to another machine.
While a source image uploads, a progress bar reports the bytes actually sent and then shows when the image is loading. On platforms requiring a special authentication fallback, the indicator stays indeterminate rather than showing an estimated percentage.
- Brush, soft edges, hardness, opacity, pen pressure, temporary erasing and shape tools.
- A compact **↑ upload / ⛶ fit / pin** toolbar, with a mode selector at the upper right. Enable the pin (**Keep**) to keep the image fitted and centered; plain wheel then navigates the workflow, while `Ctrl` + wheel still changes brush size and `Shift` + wheel changes hardness.
- A compact **↑ upload / ⛶ fit / pin** toolbar, with a mode selector at the upper right. The pin (**Keep**) preserves the current view; use **Fit** first if desired. Plain wheel then navigates the workflow, while `Ctrl` + wheel still changes brush size and `Shift` + wheel changes hardness.
- Resize the editor horizontally or vertically. The pin state and node dimensions survive workflow reload.
- The connected **Adachi Inpaint Workbench** node hosts the painting controls and sampling parameters, leaving more space for the picture. Its painting tools, mask mode and fill content fold behind an arrow; **Undo / Redo / Invert / Fill / Clear / Import / Export** remain visible. Fold state is saved with the workflow.
- Bounded display buffers and cached image/mask compositing reduce redraw cost at large graph zoom; the saved mask retains its source resolution.
@@ -92,6 +111,8 @@ Generate, inspect the result in **Image Compare**, then click **Adopt this resul
After adoption, the result appears in the selected area and the editor returns to the repaint mask, ready for another pass. Select **◐ blend layers** to paint the layer mask: brush keeps new pixels, **Alt** restores original pixels, and soft edges blend seams. The blend mask is independent of the repaint mask. While editing the blend mask, **hold the right mouse button** to preview the original and release it to return to the workspace. To select a new region, switch to **◌ repaint mask** and right-click (or press Escape); the visible result is merged before the selection is released. Left-click places the next region. The **↶ Undo** button undoes strokes in the active mask. Hover the bottom controls for instructions. For a generated batch, select the desired result in Image Compare before adopting it. A result tagged with an older crop is rejected after selecting another region.
Merging an adopted crop can take time on a slow connection: upload progress is shown while the selection is being released. If the upload fails, the selection remains in place and the operation can be retried.
### Inpaint the whole image or just the masked region
| Sampling area | Behaviour |
@@ -121,7 +142,7 @@ Save group states for your own setups: text-to-image, inpainting, model variants
![Group Bypass Presets with a preset list on the left and per-group states on the right](preview/group-bypass-presets.png)
Choose a preset on the left, configure **Enabled / Bypassed / Keep current** on the right, or apply it from the node's right-click menu. Presets are stored in the workflow and need no wires or third-party group controller. Missing groups are reported; overlapping groups give the smaller group's setting priority.
Choose a preset on the left, configure **Enabled / Bypassed / Keep current** on the right, or apply it from the node's right-click menu. Use the arrow to collapse or reopen the group details; this preference is saved in the workflow. Presets are stored in the workflow and need no wires or third-party group controller. Missing groups are reported; overlapping groups give the smaller group's setting priority.
### Automatically use the remaining branch
@@ -216,7 +237,7 @@ For modular sampling: `Prepare.pixels → VAE Encode → Set Latent Noise Mask
| Release crop selection | In **◌ repaint mask**, right-click or press Escape; left-click to select a new region |
| Undo / redo | `Ctrl+Z` / `Ctrl+Shift+Z`, or `Ctrl+Y` |
| Image zoom / pan, when unpinned | Wheel / Space + drag, middle-button drag, or the pan tool (`H`) |
| Keep the image fitted | Pin beside **Fit**; click again to unlock |
| Keep the current view | Fit first if desired, then use the pin; click again to unlock |
| Adjust editor height | Drag the bottom `↕` handle |
The painting panel also provides invert, fill, clear, PNG mask import/export, image/mask/overlay views and **Embed source in workflow**. Overlay opacity affects display only. Scrolling over buttons and parameter controls navigates the outer workflow; a gesture starting on the workflow keeps navigating it when the pointer crosses the picture.
@@ -227,6 +248,23 @@ Use the single [official template: Adachi-Workbench.json](examples/Adachi-Workbe
All example labels are English. Prompts are newly written neutral examples; the checkpoint is a placeholder for your own model. Inpaint requires uploading a source first. Crop & Blend uses the same connected editor, so no second workflow is needed. For model families with separate model, text encoder and VAE loaders, replace the checkpoint block with compatible loaders and connect the matching outputs.
## Model and workflow compatibility
The inpaint node uses ComfyUI `MODEL`, `CONDITIONING`, `VAE`, image and mask inputs with `common_ksampler`. Matching interfaces alone do not establish compatibility with a model's required conditioning, sampler, latent format or reference-image pipeline. Use a matched model, encoder and VAE; the **inpaint_model** switch is for compatible dedicated inpainting architectures.
| Family / case | Evidence | What remains unverified |
| --- | --- | --- |
| WAI (SDXL-based) | End-to-end 512×512 generation without LoRA, including two- and six-step runs; template text-to-image and inpaint branches both ran. | This does not establish support for every SDXL checkpoint, LoRA or extension. |
| SD 1.5 | Workflow validation plus real checkpoint VAE encode/decode and masked compositing on CPU; `[1,4,8,8]` latent. | The sampler was mocked in the VAE test; a full diffusion generation has not been verified. |
| Anima / Wan-derived image VAE | Single-frame 5D latents and a real, randomly initialized Wan VAE were exercised. The user reported a remote workflow running after moving the CLIP encoder out of a subgraph. | No independently reproducible end-to-end run with real Anima weights was available in this audit. |
| FLUX, SD3, Qwen and other families | Not verified. | Their model-specific conditioning, guidance, sampling, reference-image and VAE requirements may need separate workflow nodes; compatibility cannot be assumed. |
Only still-image 4D latents and single-frame 5D `[B,C,1,H,W]` latents are supported; multi-frame video is rejected. To try a different model, first run its normal ComfyUI workflow, connect its matching model/conditioning/VAE to the inpaint node, start with **inpaint_model** off unless the architecture requires it, and check the protected pixels and output resolution. Start with a small image and few steps.
The standalone page requires this plugin's **Inline Mask**, **Adachi Inpaint Workbench** and **Image Compare** nodes. It follows supported connections, Get/Set, Use Everywhere and subgraph execution IDs, and preserves intermediate processing in the tested pipelines. Unknown custom loaders, LoRA classes, prompt fields or virtual routes may remain controlled by the workflow rather than being editable in the page; custom LoRA override cannot be guaranteed for unrecognized loaders. Select explicit nodes under **Workflow connections** when discovery is ambiguous and inspect the result for complex graphs. The page validates and runs the chosen comparison output and its dependencies: unrelated `Save Image` or other output branches do not automatically run there; queue the original workflow when those outputs are needed.
Group presets capture group-level enabled/bypassed states, not every node's individual mixed or muted state. Masks are embedded in saved workflows, while source images and unmerged crop layers normally reference files under `input/`; move those files too, or embed/merge before sharing. Source undo history is limited to the current browser session. Comparison previews are process-local and may expire on eviction or server restart; deployments with multiple backend instances need requests to reach the same instance. Local network-failure simulations passed, but a long-running real cloud soak test has not been performed. For crop/blend and source-history operations, use HTTPS or localhost: the browser's `crypto.randomUUID()` is unavailable in insecure contexts such as many plain-HTTP LAN addresses.
## Compatibility and important behaviour
- **Static images:** ordinary 4D `[B,C,H,W]` latents and single-frame 5D `[B,C,1,H,W]` image latents are supported, including the Wan-derived VAE path used by Anima. Supply the model family's matching VAE and conditioning. This is not multi-frame video inpainting.
@@ -270,9 +308,26 @@ node --experimental-default-type=module tests/test_compare_history.mjs
See [verification notes](VERIFICATION.md) for tested configurations and limits, [changelog](CHANGELOG.md) for updates, and [rollback instructions](ROLLBACK.md) for returning to an earlier version. Model-family and image-quality coverage is not exhaustive.
## Release history
| Versions | Highlights and fixes |
| --- | --- |
| 2.1.1 | Streamlined comparison Fit controls, moved repaint-area choices below Generate, and combined color-paint controls with the lower toolbar. |
| 2.1.0 | Color-paint layer, crop merge progress, workspace comparison zoom, reordered controls, collapsible prompts and GPL-3.0-only licensing. |
| 2.0.1 | Weak-network retry and cancellation, preview resource cleanup, rollback races, dense-mask memory reduction and safer malformed-workflow handling. |
| 2.0.0 | Standalone light/dark workbench, workflow discovery, editable generation, task progress, result recovery and unified template. |
| 1.0.1 / 1.0.0 / 0.9 | Stable release; preview reload/retry, in-memory comparisons and real upload progress. |
| 0.8 | Crop adoption and dimension sync, latest-result download, different-sized upload refresh. |
| 0.7 | Preserve masks on replacement, source download and pin-current-view behavior. |
| 0.6 | Inline crop/blend, locked selection and dimension sync, right-click preview/reselection and draft recovery; magic wand and quick selection were later removed. |
| 0.5 | Group presets and automatic exclusive switch, mask retention, 5D image VAE support and canvas performance fixes. |
| 0.4 / 0.3 / 0.2 | Inline controls and vertical resizing, comparison/adoption, temporary eraser and bilingual node UI. |
The [complete changelog](CHANGELOG.md) lists individual releases and fixes.
## Credits and license
Created by **Adachi / 安达**. Licensed under [MIT](LICENSE).
Created by **Adachi / 安达**. The current project is licensed under [GNU GPL-3.0-only](LICENSE). You may use it commercially; when distributing the covered program or modifications, follow GPL-3.0 source and license requirements. Releases through v2.0.1 were distributed under MIT; recipients retain those earlier permissions. The original copyright and license notice is preserved in [LICENSES](LICENSES/MIT-previous-releases.txt). The current branch's GPL terms do not retroactively revoke the earlier MIT grant.
Built for [ComfyUI](https://github.com/Comfy-Org/ComfyUI), with familiar inpainting controls inspired by WebUI and hover-comparison interaction inspired by [rgthree-comfy](https://github.com/rgthree/rgthree-comfy). This repository contains its own implementation; those projects are not bundled dependencies.
+60 -5
View File
@@ -8,7 +8,7 @@
[English](README.md) · **简体中文**
[![MIT 许可证](https://img.shields.io/badge/License-MIT-38966b)](LICENSE)
[![GPL-3.0 许可证](https://img.shields.io/badge/License-GPL--3.0--only-38966b)](LICENSE)
[![ComfyUI 自定义节点](https://img.shields.io/badge/ComfyUI-Custom_Nodes-4776b4)](https://github.com/Comfy-Org/ComfyUI)
[![Python 3.10+](https://img.shields.io/badge/Python-3.10%2B-4776b4)](pyproject.toml)
[![插件检查](https://github.com/adachi0130/Adachi-Inpaint-Workbench/actions/workflows/ci.yml/badge.svg)](https://github.com/adachi0130/Adachi-Inpaint-Workbench/actions/workflows/ci.yml)
@@ -37,6 +37,17 @@
## 功能介绍
<details open>
<summary>工作台实机截图</summary>
![浅色工作台中的内联蒙版和工作流设置](preview/workbench-light.png)
![深色工作台中的图片对比分割线](preview/workbench-compare-dark.png)
*截图来自实际运行的插件,图片是自制示例;对比结果使用 CPU 反相演示分割线,并非 AI 重绘结果。*
</details>
### Adachi Workbench · 黑白主题
在任意安达重绘工作台节点中点击「打开安达工作台」。工作流需包含本插件的内联蒙版、重绘和图像对比节点,但节点编号、标题、预设名称及模型文件名均不需与示例一致。页面沿所选重绘节点的实际连线寻找图像和结果;多个候选无法唯一判断时,在「工作流连接」中选择。已启用的组忽略预设节点提供预设按钮,缺失或存在多个控制器时会提醒。
@@ -47,8 +58,12 @@
画布与配置之间的分隔条可以左右拖动,宽度会记住;双击恢复默认,方向键可微调。窄屏保持上下排列,右上角可切换并记住黑白主题。操作说明放在悬停提示中。
生成按钮、宽度、高度和重绘幅度位于设置面板靠上位置。正面和负面提示词可以折叠,腾出更多操作空间。
生成时可以继续涂蒙版、缩放、移动和调整参数,已提交任务使用当时的快照。工作流设置上方显示真实阶段和采样步数;结果默认滑动对比,前后图共用一个切换图标,点击「采用」才替换原图。裁切融合采用后返回编辑,保留选区与蒙版。下方可回退或选择之前的原图,历史限当前已加载的浏览器会话。
独立工作台的滑动对比和前后图视图支持在图片上滚动鼠标滚轮缩放、按住鼠标中键拖动画面,点击「适应」恢复适合画布的视图。工作流内的图像对比节点仍使用滚轮操作工作流画布。
图像对比节点和网页按本次任务编号找回丢失的结果通知。图片加载显示真实接收字节、已知总大小时的百分比及解码状态;弱网失败自动重试,重连后可恢复,不产生预览文件。服务器重启或缓存淘汰造成的预览过期仍需重新运行。后端升级需重启 ComfyUI,仅前端改动强刷网页即可。
[官方模板](examples/Adachi-Workbench.json) 仅作为起点,不是必需的固定工作流。已验证不同编号和预设名的副本,以及完全新建的多链路工作流。界面默认英文,在 ComfyUI 设置中选择简体中文后显示中文,其他语言暂时回退英文。模型权重须与原工作流匹配;缺失文件会列出文件名和目标目录。
@@ -57,10 +72,14 @@
在**内联蒙版**上传、拖入或粘贴原图,然后直接在节点画布上涂抹。普通蒙版模式上传图片时会自动填写已连接的局部重绘宽高;替换同尺寸图片时保留蒙版,尺寸不同则载入新图并重置蒙版。蒙版在浏览器内更新,随工作流保存,不需要每画一笔都保存、加载磁盘文件。
两种模式都可以点击左下角的 **✎ 彩色画笔**直接在图像上绘制。可手动选色,或按住 **Alt 并点击图像**吸取当前可见颜色;使用 **Ctrl + 鼠标滚轮**调整笔径。彩色画笔使用独立图层,提供自己的撤销和重做,画完的图像会作为后续重绘的原图。再次点击画笔按钮可回到蒙版编辑。
如果浏览器草稿存储空间不足,画笔图层可能会保存为 ComfyUI 输入目录中的 PNG,而不是嵌入工作流;将工作流转移到其他机器时,也需要带上引用的文件。
上传原图时,进度条显示浏览器实际发送的数据比例,上传完成后提示正在加载图片。需要特殊认证回退的平台会显示不定进度,不会虚构百分比。
- 支持画笔软边、硬度、不透明度、数位笔压感、临时擦除和形状工具。
- 顶部使用**↑ 上传、⛶ 适应、图钉**,右上角切换模式。图钉的悬停提示为「保持」:开启后图片持续适应并居中,普通滚轮控制工作流背景,`Ctrl` + 滚轮调整笔径,`Shift` + 滚轮调整硬度。
- 顶部使用**↑ 上传、⛶ 适应、图钉**,右上角切换模式。图钉的悬停提示为「保持」:开启后保持当前视图;需要适应画面时请先点击「适应」再钉住,普通滚轮控制工作流背景,`Ctrl` + 滚轮调整笔径,`Shift` + 滚轮调整硬度。
- 支持横向、纵向调整编辑区大小;图钉状态和节点尺寸都能随工作流保存。
- 连到 **安达重绘工作台**后,画笔工具和采样参数显示在重绘节点内,让图片编辑区保持简洁。画笔工具、蒙版内外和填充内容收进箭头折叠区,**撤销、重做、反转、全选、清空、导入蒙版、导出**整行始终显示;折叠状态随工作流保存。
- 放大工作流时限制显示画布的分配尺寸,并缓存原图/蒙版合成,减少反复重绘;实际保存的蒙版仍保持原图分辨率。
@@ -92,6 +111,8 @@
采用后结果显示在选区中,编辑器回到重绘蒙版,可直接继续生成。点击 **◐ 图层融合**可编辑融合蒙版:画笔保留新图,按住 **Alt** 擦回原图,软笔刷可融合接缝;融合蒙版与重绘蒙版相互独立。编辑融合蒙版时,**按住右键看原图,松开恢复工作区**。要选择新区域,请切回 **◌ 重绘蒙版**后右键,或按 Escape;当前可见结果会先合并,再取消选定,接着左键选择新区域。**↶ 撤销**可撤销当前蒙版的涂抹,鼠标悬停底部控件可查看说明。批量生成时,在图像对比中选中想要的结果再采用;换到其他选区后,带有旧选区标记的结果会拒绝回贴。
网络较慢时,取消选区前合并已采用的裁片可能需要一些时间,界面会显示上传进度;上传失败会保留选区,可再次尝试。
### 整图重绘与蒙版区域重绘
| 采样区域 | 工作方式 |
@@ -121,7 +142,7 @@
![组忽略预设:左侧选择预设,右侧设置每个组的状态](preview/group-bypass-presets.png)
左侧点选预设,右侧配置各组**打开 / 忽略 / 保持当前**,也能通过节点右键菜单应用。预设随工作流保存,不需要连线,也不依赖第三方组控制节点。已删除的组会显示提示;分组重叠时,较小组的设置优先。
左侧点选预设,右侧配置各组**打开 / 忽略 / 保持当前**,也能通过节点右键菜单应用。点击箭头可折叠或展开右侧分组详情,状态随工作流保存。预设随工作流保存,不需要连线,也不依赖第三方组控制节点。已删除的组会显示提示;分组重叠时,较小组的设置优先。
### 自动使用剩下的一路输入
@@ -216,7 +237,7 @@ Refiner 输入是可选项。只有需要兼容的第二阶段模型时才连接
| 取消裁切选定 | 在 **◌ 重绘蒙版**中右键或按 Escape,再左键选择新区域 |
| 撤销 / 重做 | `Ctrl+Z` / `Ctrl+Shift+Z`,或 `Ctrl+Y` |
| 未固定时的图片缩放 / 移动 | 滚轮 / 空格拖动、中键拖动,或移动工具 `H` |
| 保持图片适应 | 点击「适应」旁的图钉,再点一次解除 |
| 保持当前视图 | 需要时先点击「适应」,再点击图钉;再点一次解除 |
| 调整编辑区高度 | 拖动底部 `↕` 手柄 |
画笔面板还提供反转、全选、清空、PNG 蒙版导入/导出、原图/蒙版/叠加视图和**原图嵌入工作流**。叠加不透明度只影响显示。鼠标在按钮、参数区滚动时控制外部工作流;从工作流背景开始的连续滚轮手势,移入图片后仍控制工作流。
@@ -227,6 +248,23 @@ Refiner 输入是可选项。只有需要兼容的第二阶段模型时才连接
模板标题使用英文,提示词为重新编写的中性示例,模型为占位名称,需自行选择。修图前请先上传原图;裁切融合直接切换同一个内联蒙版即可。使用分离模型、文本编码器和 VAE 的模型家族时,请用匹配的加载节点替换 checkpoint 区块并连接对应输出。
## 模型和工作流兼容性
重绘节点使用 ComfyUI 的 `MODEL`、`CONDITIONING`、`VAE`、图像和蒙版输入,并调用 `common_ksampler`。接口能接上,并不代表模型所需的条件、采样器、潜空间格式或参考图流程一定兼容;请使用相互匹配的模型、文本编码器和 VAE。**inpaint_model** 只用于兼容的专用修复架构。
| 模型/情况 | 已有证据 | 尚未验证 |
| --- | --- | --- |
| WAI(基于 SDXL) | 无 LoRA 的 512×512 完整生成已通过,包括 2 步、6 步测试;官方模板的生图和修图分支都已运行。 | 不代表所有 SDXL 模型、LoRA 或扩展都已验证。 |
| SD 1.5 | 工作流校验通过;本次用真实 checkpoint 的 VAE 在 CPU 上完成编码、解码和蒙版回贴,潜空间为 `[1,4,8,8]`。 | VAE 测试中的采样器使用模拟实现,尚未完成完整扩散生成验证。 |
| Anima/Wan 系图像 VAE | 已测试单帧 5D 潜空间和真实但随机初始化的 Wan VAE;主人反馈曾将 CLIP 编码器移出子图后跑通远程工作流。 | 本次没有可独立复核的真实 Anima 权重端到端生成记录。 |
| FLUX、SD3、Qwen 等其他模型 | 尚未验证。 | 专用条件、引导、采样、参考图及 VAE 流程可能需要额外节点,不能直接保证兼容。 |
目前只支持静态图 4D 潜空间或单帧 5D `[B,C,1,H,W]` 潜空间,真正的多帧视频会被拒绝。尝试新模型时,先确保它自己的 ComfyUI 流程能运行,再把匹配的模型、条件和 VAE 接到重绘节点;除非架构明确需要,否则先关闭 **inpaint_model**,用小图和少量步数检查尺寸与蒙版外像素。
独立页面需要本插件的**内联蒙版、安达重绘工作台和图像对比**节点。已测试的流程可跟随连线、Get/Set、Use Everywhere 和子图执行编号,并保留中间处理链;未知自定义加载器、LoRA、提示词字段或虚拟路由可能仍由原工作流控制,不能保证在页面里识别和编辑,也不能保证自定义 LoRA 覆盖适用于未知加载器。候选不唯一时请在**工作流连接**中选择,复杂流程应检查实际输出。页面只校验并运行所选图像对比及其依赖,不会自动运行无依赖关系的「保存图像」等其他输出;需要它们时请运行原工作流。
组预设保存的是组级打开/忽略状态,不会完整记录组内逐节点的混合或静音状态。蒙版随工作流保存,原图和未合并的裁切图层默认引用 `input/` 文件;换机时请带上文件,或先嵌入原图/合并图层。原图回退历史仅限当前浏览器会话。对比预览保存在单个服务进程内,重启或被淘汰后可能失效;多实例部署需保证请求落到同一个后端。本地已模拟断网重连,但未做仙宫云长期实机压测。裁切融合和历史记录使用浏览器的 `crypto.randomUUID()`:请优先使用 HTTPS 或 localhost,部分普通 HTTP 局域网地址下可能不可用。
## 兼容性与重要行为
- **静态图片:**支持常规四维 `[B,C,H,W]` 和单帧五维 `[B,C,1,H,W]` 图像潜空间,包括 Anima 使用的 Wan 衍生 VAE 路径;需提供该模型配套的 VAE 和文本条件,不是多帧视频重绘。
@@ -270,9 +308,26 @@ node --experimental-default-type=module tests/test_compare_history.mjs
已测试配置和范围见[验证记录](VERIFICATION.md),版本变化见[更新日志](CHANGELOG.md),恢复旧版本见[回滚说明](ROLLBACK.md)。模型家族和实际画质测试并非全覆盖。
## 版本更新摘要
| 版本 | 主要功能和修复 |
| --- | --- |
| 2.1.1 | 精简对比适应按钮、将重绘画布选项移至生成按钮下方,并把彩色画笔并入左下角工具栏。 |
| 2.1.0 | 彩色画笔图层、裁切合并进度、工作台对比缩放、常用参数上移、提示词折叠及 GPL-3.0-only 许可证。 |
| 2.0.1 | 弱网重试与取消、预览资源清理、回退竞态、大蒙版内存优化和异常工作流校验。 |
| 2.0.0 | 独立黑白工作台、工作流发现、生成中编辑、进度、结果恢复和统一模板。 |
| 1.0.1/1.0.0/0.9 | 稳定版、预览重试与手动刷新、内存对比、真实上传进度。 |
| 0.8 | 裁切采用和尺寸同步、下载最新结果、不同尺寸上传刷新。 |
| 0.7 | 替换图片保留蒙版、下载原图、图钉保持当前视图。 |
| 0.6 | 裁切融合、选区和尺寸同步、右键预览/重选与草稿恢复;魔棒和快速选择后来已移除。 |
| 0.5 | 组忽略预设、自动二选一、保留蒙版、单帧 5D 图像 VAE 和画布性能修复。 |
| 0.4/0.3/0.2 | 内联控件与纵向调整、图片对比/采用、临时橡皮擦及中英文节点界面。 |
逐版本的详细修复见[完整更新日志](CHANGELOG.md)。
## 致谢与许可证
由 **Adachi / 安达** 制作,采用 [MIT 许可证](LICENSE)。
由 **Adachi / 安达** 制作,当前项目采用 [GNU GPL-3.0-only 许可证](LICENSE)。GPL 允许商业使用;分发受许可程序或其修改版时,应遵守 GPL-3.0 的源码与许可要求。v2.0.1 及更早版本按 MIT 发布,已有接收者仍保留此前获得的权限;原版权和许可通知保留在 [LICENSES](LICENSES/MIT-previous-releases.txt),当前分支改用 GPL 不会撤销先前的 MIT 授权。
基于 [ComfyUI](https://github.com/Comfy-Org/ComfyUI) 构建,重绘交互参考 WebUI,鼠标划线对比体验参考 [rgthree-comfy](https://github.com/rgthree/rgthree-comfy)。代码由本仓库独立实现,这些项目不作为捆绑依赖。
+5
View File
@@ -1,5 +1,10 @@
# Verification record
## 2026-11-12 · model compatibility and documentation
- A real SD 1.5 checkpoint VAE was loaded on CPU and tested through the plugin: 64×64 encode, mock sampling, decode and masked compositing succeeded with a [1,4,8,8] latent, finite output and exact protected pixels. This is not a complete diffusion-generation test.
- The installed 2.0.1 frontend was captured in an isolated ComfyUI instance. Screenshots use a synthetic image and a CPU ImageInvert result to demonstrate the interface, not an AI-generated inpaint.
## 2.0.1 · stability audit (2026-11-10)
- 68 Python regression tests and all 20 JavaScript test files cover new cancellation, stalled decoding/upload, corrupt-cache retry, dense/sparse/edge masks, bounded cache metadata, partial rollback failures, concurrent completed edits and malformed input mappings.
+29 -5
View File
@@ -1,4 +1,6 @@
"""Portable inline inpainting nodes for ComfyUI (MIT licensed).
"""Portable inline inpainting nodes for ComfyUI (GPL-3.0-only).
SPDX-License-Identifier: GPL-3.0-only
All image and crop operations below are original implementations. No code from
AUTOMATIC1111, Forge or their AGPL components is included.
@@ -326,6 +328,7 @@ class AdachiInlineMask:
"crop_state": ("STRING", {"default": "{}"}),
"blend_data": ("STRING", {"default": ""}),
"layer_image": ("STRING", {"default": ""}),
"paint_data": ("STRING", {"default": "", "multiline": False, "tooltip": "Transparent color paint layer PNG."}),
}}
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE")
@@ -341,6 +344,13 @@ class AdachiInlineMask:
for value in (image, mask_data, source_data, json.dumps(kwargs, sort_keys=True, default=str)):
digest.update(str(value).encode("utf-8"))
digest.update(b"\x00")
paint_data = kwargs.get("paint_data", "")
if paint_data and not paint_data.startswith("data:"):
try:
stat = _input_path(paint_data).stat()
digest.update(f"{stat.st_size}:{stat.st_mtime_ns}".encode("utf-8"))
except (ValueError, OSError):
return float("nan")
if not source_data:
# The source is a stable input file; hashing its size and mtime is
# enough to detect a replacement and avoids reading a full high-res
@@ -353,7 +363,7 @@ class AdachiInlineMask:
return float("nan")
return digest.hexdigest()
def load(self, image="", mask_data="", source_data="", edit_mode="mask", crop_state="{}", blend_data="", layer_image="", result=None):
def load(self, image="", mask_data="", source_data="", edit_mode="mask", crop_state="{}", blend_data="", layer_image="", paint_data="", result=None):
raster = _data_image(source_data) if source_data else _read_raster(_input_path(image))
rgba = raster.convert("RGBA")
pixels = torch.from_numpy(np.asarray(rgba.convert("RGB"), dtype=np.float32).copy() / 255.0)[None]
@@ -369,11 +379,24 @@ class AdachiInlineMask:
state = _crop_state(crop_state)
if state.get("locked"):
x, y, width, height = _crop_box(state, pixels.shape[2], pixels.shape[1])
composite = _blend_layer(pixels, _load_layer(layer_image), blend_data, state)
composite = _paint_layer(_blend_layer(pixels, _load_layer(layer_image), blend_data, state), paint_data)
return composite[:, y:y+height, x:x+width].clone(), mask[:, y:y+height, x:x+width].clone(), composite
pixels = _paint_layer(pixels, paint_data)
return pixels, mask, pixels
def _paint_layer(pixels, paint_data):
"""Composite a full-image RGBA paint layer without changing the repaint mask."""
if not paint_data:
return pixels
layer = (_data_image(paint_data) if paint_data.startswith("data:") else _read_raster(_input_path(paint_data))).convert("RGBA")
if layer.size != (pixels.shape[2], pixels.shape[1]):
raise ValueError("The paint layer dimensions must match the source image.")
rgba = torch.from_numpy(np.asarray(layer, dtype=np.float32).copy() / 255.0)
alpha = rgba[..., 3:4].unsqueeze(0)
return pixels * (1.0 - alpha) + rgba[..., :3].unsqueeze(0) * alpha
class AdachiInpaintPrepare:
@classmethod
def INPUT_TYPES(cls):
@@ -971,6 +994,7 @@ class AdachiLayerCommit:
"result": ("IMAGE",), "image": ("STRING", {"default": ""}),
"source_data": ("STRING", {"default": ""}), "crop_state": ("STRING", {"default": "{}"}),
"blend_data": ("STRING", {"default": ""}), "target": ("STRING", {"default": ""}),
"paint_data": ("STRING", {"default": ""}),
}}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "compose"
@@ -978,12 +1002,12 @@ class AdachiLayerCommit:
CATEGORY = "_Adachi/internal"
DESCRIPTION = "Internal layer sink. Use Inline Mask in Crop & Blend mode."
def compose(self, result, image="", source_data="", crop_state="{}", blend_data="", target=""):
def compose(self, result, image="", source_data="", crop_state="{}", blend_data="", target="", paint_data=""):
raster = (_data_image(source_data) if source_data else _read_raster(_input_path(image))).convert("RGB")
original = torch.from_numpy(np.asarray(raster, dtype=np.float32).copy()/255)[None]
state = _crop_state(crop_state)
generated = _image_tensor(result).cpu()
composite = _blend_layer(original, generated, blend_data, state)
composite = _paint_layer(_blend_layer(original, generated, blend_data, state), paint_data)
layer = _store_layer(generated[-1])
return {"ui": {"adachi_layer": [{"target": target, "job": state.get("job", ""), "image": layer}]}, "result": (composite,)}
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

+2 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "adachi-inpaint-workbench"
version = "2.0.1"
version = "2.1.1"
description = "Inline mask painting, crop blending and a standalone inpainting workbench for ComfyUI"
requires-python = ">=3.10"
license = { file = "LICENSE" }
@@ -8,6 +8,7 @@ readme = "README.md"
authors = [{ name = "Adachi" }]
dependencies = ["numpy", "Pillow", "torch"]
classifiers = [
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
]
+7
View File
@@ -0,0 +1,7 @@
import assert from 'node:assert/strict';
import {ImageCompareView} from '../web/compare.js';
const view=Object.create(ImageCompareView.prototype),events=[];
Object.assign(view,{ready:true,pendingOutputs:0,targetSelect:{value:'7'},results:[{}],index:0,progressBox:{hidden:true},refreshTargets(){},updateButton(){},status(s,e){events.push(['status',s,e]);},loading(s,v){this.progressBox.hidden=false;events.push(['progress',s,v]);},options:{async onAdopt(id,image,{onProgress}){onProgress({phase:'download',loaded:20,total:100});onProgress({phase:'upload',loaded:30,total:100});onProgress({phase:'processing'});}}});
await view.adopt();assert.equal(view.busy,false);assert.equal(view.progressBox.hidden,true);const progress=events.filter(e=>e[0]==='progress');assert.equal(progress.length,4);assert.equal(progress[1][2][0].loaded,20);assert.equal(progress[2][2][0].loaded,30);
view.options.onAdopt=async()=>{throw Error('network failure')};await view.adopt();assert.equal(view.progressBox.hidden,true);assert.equal(view.busy,false);assert(events.some(e=>e[0]==='status'&&e[1]==='network failure'&&e[2]));
console.log('Adoption progress: actual download/upload bytes, loading phase and failed retry cleanup passed');
+21
View File
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
class Element {
constructor(tag){this.tag=tag;this.style={};this.dataset={};this.classList={toggle(){}};this.children=[];this.width=0;this.height=0;this.value='';}
append(...children){this.children.push(...children)} prepend(...children){this.children.unshift(...children)} setAttribute(){} hasPointerCapture(){return false} setPointerCapture(){}
getContext(){return {save(){},restore(){},beginPath(){},rect(){},clip(){},arc(){},fill(){},moveTo(){},lineTo(){},stroke(){},translate(){},clearRect(){},drawImage(){},getImageData(){return {data:[12,34,56,255]}}};}
toDataURL(){return 'data:image/png;base64,paint';}
}
globalThis.document={createElement:tag=>new Element(tag)};globalThis.ResizeObserver=class {observe(){} disconnect(){}};
globalThis.Image=class {constructor(){this.width=16;this.height=12;} async decode(){if(this.src==='bad')throw Error('bad image');}};
const {ColorPaint}=await import('../web/color_paint.js');
const editor={image:{width:16,height:12},ready:true,view:'overlay',brushSize:8,brushOpacity:100,stage:new Element('div'),root:new Element('div'),canvas:new Element('canvas'),options:{drawSource:()=>false},point:e=>({x:e.x,y:e.y}),pointerDown(){},pointerMove(){},pointerUp(){},schedule(){},status(){}};
const crop={mode:'mask',bar:new Element('div'),region:{locked:false}},widget={value:''};let changes=0;
const paint=new ColorPaint(editor,crop,widget,()=>changes++);assert.equal(crop.bar.children[0],paint.controls);paint.button.onclick({preventDefault(){},stopPropagation(){}});
const event={button:0,pointerId:1,x:2,y:3,preventDefault(){},stopPropagation(){}};
editor.pointerDown(event);assert.equal(editor.pointerId,1);editor.pointerUp(event);assert.equal(editor.pointerId,null);assert.match(widget.value,/data:image\/png/);assert.equal(changes,1);
const saved=widget.value;editor.pointerDown({...event,altKey:true});assert.equal(paint.color,'#0c2238');assert.equal(widget.value,saved);
await assert.rejects(()=>paint.prepareRestore('bad',16,12));await assert.rejects(()=>paint.prepareRestore('anything',17,12));
const loaded=await paint.prepareRestore('anything',16,12);paint.commitRestore(loaded,16,12);assert.equal(paint.undo.length,0);
paint.clearRegion({x:0,y:0,width:4,height:4});assert.equal(changes,2);paint.clear();assert.equal(widget.value,'');
widget.value='old';let finish;paint.prepareRestore=()=>new Promise(resolve=>{finish=resolve});const restoring=paint.restore('old');paint.clear();finish(new Image());assert.equal(await restoring,false);assert.equal(paint.layer.width,0);assert.equal(paint.restoring,false);
console.log('Color painting: stroke commits, Alt sampling, immutable restore validation and region clearing pass');
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import {CompareViewport} from '../web/compare_viewport.js';
import {ImageCompareView} from '../web/compare.js';
const v=new CompareViewport();
const size=[1000,600,800,400],at=[235,417];
const original=v.transform(...size);
assert.equal(original.scale,1.25);assert.equal(original.y,50);
const pixel=t=>[(at[0]-t.x)/t.scale,(at[1]-t.y)/t.scale];
const initial=pixel(original);
v.zoomAt(...size,...at,-120);
assert.ok(v.zoom>1);const enlarged=v.transform(...size);
assert.ok(Math.abs(pixel(enlarged)[0]-initial[0])<1e-9);
assert.ok(Math.abs(pixel(enlarged)[1]-initial[1])<1e-9,'wheel zoom keeps the image point under the pointer');
v.pan(20,-10);const moved=v.transform(...size);assert.equal(moved.x,enlarged.x+20);assert.equal(moved.y,enlarged.y-10);
for(let i=0;i<500;i++)v.zoomAt(...size,...at,-120);assert.equal(v.zoom,16);
for(let i=0;i<1000;i++)v.zoomAt(...size,...at,120);assert.equal(v.zoom,.1);
v.fit();assert.deepEqual(v.transform(...size),original);
v.zoomAt(0,0,800,400,0,0,120);assert.equal(v.zoom,1);
console.log('Compare viewport: pointer-anchored zoom, panning, limits, fit and zero-size protection pass');
{
const draws=[];const context={setTransform(){},clearRect(){},save(){},restore(){},beginPath(){},rect(){},clip(){},drawImage(image){draws.push(image);}};
const view=Object.create(ImageCompareView.prototype),before={width:800,height:400},after={width:800,height:400};
Object.assign(view,{viewport:new CompareViewport(),workspaceZoom:false,displayMode:'compare',ready:true,before,after,hovered:true,split:.5,version:1,divider:{style:{}},canvas:{width:0,height:0,clientWidth:1000,getBoundingClientRect:()=>({width:1000,height:600,left:0,top:0}),getContext:()=>context},options:{onGraphWheel:()=>graphWheels++},schedule:()=>schedules++});
let graphWheels=0,schedules=0;globalThis.devicePixelRatio=1;
const event={deltaY:-120,deltaMode:0,clientX:235,clientY:417,preventDefault(){},stopPropagation(){}};
view.wheel(event);assert.equal(graphWheels,1,'normal graph node wheel must still navigate the workflow');
view.enableWorkspaceZoom(true);view.wheel(event);assert.equal(graphWheels,1);assert.ok(view.viewport.zoom>1);
const shared=view.transform();
view.setDisplayMode('before');view.render();assert.deepEqual(draws.splice(0),[before]);assert.deepEqual(view.transform(),shared);
view.setDisplayMode('after');view.render();assert.deepEqual(draws.splice(0),[after]);assert.deepEqual(view.transform(),shared);
view.setDisplayMode('compare');view.render();assert.deepEqual(draws.splice(0),[after,before]);
view.fitView();assert.equal(view.viewport.zoom,1);assert.ok(schedules>0);
view.viewport.pan(50,30);view.enableWorkspaceZoom(false);assert.equal(view.displayMode,'compare');assert.equal(view.viewport.panX,0);assert.equal(view.viewport.panY,0);
}
console.log('Compare integration: graph wheel routing, workspace zoom, shared before/after transform, display pixels and teardown reset pass');
+35
View File
@@ -30,6 +30,25 @@ class InlineCropTests(unittest.TestCase):
image,mask,full=backend.AdachiInlineMask().load(source_data=self.source,mask_data=url(Image.fromarray(pixels)),edit_mode='crop',crop_state=self.state)
self.assertTrue(torch.equal(image,self.original[:,16:80,32:96]));self.assertTrue(torch.equal(full,self.original))
self.assertEqual(mask.sum().item(),20);self.assertEqual(mask[0,4,3].item(),1)
def test_color_paint_composites_after_crop_layer_before_crop_without_touching_mask(self):
paint=np.zeros((96,128,4),dtype=np.uint8)
paint[20,35]=[255,0,0,255]
paint[21,36]=[0,255,0,128]
paint[4,5]=[0,0,255,255]
paint_data=url(Image.fromarray(paint))
mask=np.zeros((96,128),dtype=np.uint8);mask[20,35]=255
with patch.object(backend,'_load_layer',return_value=torch.ones(1,64,64,3)):
image,m,full=backend.AdachiInlineMask().load(source_data=self.source,mask_data=url(Image.fromarray(mask)),edit_mode='crop',crop_state=self.state,layer_image='layer.png',paint_data=paint_data)
self.assertTrue(torch.equal(image,full[:,16:80,32:96]))
self.assertTrue(torch.equal(full[0,20,35],torch.tensor([1.,0.,0.])))
self.assertTrue(torch.allclose(full[0,21,36],torch.tensor([127/255,1.,127/255])))
self.assertTrue(torch.equal(full[0,4,5],torch.tensor([0.,0.,1.])))
self.assertEqual(m.sum().item(),1)
flat,_,_=backend.AdachiInlineMask().load(source_data=self.source,paint_data=paint_data)
self.assertTrue(torch.equal(flat[0,20,35],torch.tensor([1.,0.,0.])))
self.assertNotEqual(backend.AdachiInlineMask.IS_CHANGED(source_data=self.source),backend.AdachiInlineMask.IS_CHANGED(source_data=self.source,paint_data=paint_data))
with self.assertRaisesRegex(ValueError,'dimensions must match'):
backend.AdachiInlineMask().load(source_data=self.source,paint_data=url(Image.new('RGBA',(1,1))))
def test_layer_blend_preserves_outside_and_supports_batch_and_soft_edges(self):
alpha=np.zeros((64,64),dtype=np.uint8);alpha[:,:32]=255;alpha[:,32:48]=128
result=torch.stack([torch.zeros(64,64,3),torch.ones(64,64,3)])
@@ -51,6 +70,22 @@ class InlineCropTests(unittest.TestCase):
descriptor=a['ui']['adachi_layer'][0]['image'];name=descriptor['subfolder']+'/'+descriptor['filename']
image,_,full=backend.AdachiInlineMask().load(source_data=self.source,edit_mode='crop',crop_state=self.state,layer_image=name)
self.assertTrue(torch.equal(image,torch.ones(1,64,64,3)));self.assertTrue(torch.equal(full,a['result'][0][:1]))
def test_legacy_layer_sink_applies_paint_above_generated_layer(self):
paint=np.zeros((96,128,4),dtype=np.uint8);paint[20,35]=[255,0,0,255]
with tempfile.TemporaryDirectory() as folder,patch.dict('sys.modules',{'folder_paths':types.SimpleNamespace(get_input_directory=lambda:folder)}):
output=backend.AdachiLayerCommit().compose(torch.ones(1,64,64,3),source_data=self.source,crop_state=self.state,paint_data=url(Image.fromarray(paint)))['result'][0]
self.assertTrue(torch.equal(output[0,20,35],torch.tensor([1.,0.,0.])))
self.assertTrue(torch.equal(output[0,20,36],torch.ones(3)))
def test_paint_input_file_preserves_transparency_and_changes_fingerprint(self):
pixels=np.zeros((96,128,4),dtype=np.uint8);pixels[20,35]=[255,0,0,255]
with tempfile.TemporaryDirectory() as folder,patch.dict('sys.modules',{'folder_paths':types.SimpleNamespace(get_input_directory=lambda:folder)}):
path=Path(folder)/'paint.png';Image.fromarray(pixels).save(path)
a=backend.AdachiInlineMask.IS_CHANGED(source_data=self.source,paint_data='paint.png')
result,_,_=backend.AdachiInlineMask().load(source_data=self.source,paint_data='paint.png')
self.assertTrue(torch.equal(result[0,20,35],torch.tensor([1.,0.,0.])))
path.unlink()
self.assertTrue(np.isnan(backend.AdachiInlineMask.IS_CHANGED(source_data=self.source,paint_data='paint.png')))
self.assertIsInstance(a,str)
def test_invalid_regions_or_result_sizes_fail_before_writing(self):
for change in ({'x':-1},{'y':80},{'width':65},{'height':0},{'x':True}):
with self.subTest(change=change),self.assertRaises(ValueError):
+8
View File
@@ -105,6 +105,14 @@ const namedOnly = {nodes:[{type:"AdachiInlineMask", widgets_values_named:{source
await prepareInlineDraft(namedOnly,{fits:()=>false,upload:async()=>"named.png"});
assert.equal(namedOnly.nodes[0].widgets_values_named.source_data,"");
assert.equal(namedOnly.nodes[0].widgets_values_named.mask_data,"keep");
const painted={nodes:[{type:'AdachiInlineMask',widgets_values:['file.png','mask','','crop','region','blend','layer.png',large],widgets_values_named:{paint_data:'stale'}}]};
await prepareInlineDraft(painted,{fits:g=>g.nodes[0].widgets_values[7].length<100,upload:async data=>{assert.equal(data,large);return 'adachi-inpaint/paint.png';}});
assert.equal(painted.nodes[0].widgets_values[7],'adachi-inpaint/paint.png');
assert.equal(painted.nodes[0].widgets_values_named.paint_data,'adachi-inpaint/paint.png');
assert.equal(painted.nodes[0].widgets_values[0],'file.png');
const failPaint={nodes:[{type:'AdachiInlineMask',widgets_values:['file.png','mask','','crop','region','blend','layer.png',large]}]};
await assert.rejects(prepareInlineDraft(failPaint,{fits:()=>false,upload:async()=>{throw Error('offline')}}),/offline/);
assert.equal(failPaint.nodes[0].widgets_values[7],large);
const widgets=inlineWidgetNames.map((name,index)=>({name,value:`live-${index}`}));
widgets.splice(1,0,{name:"unrelated",value:"untouched"});
const snapshot={widgets_values:Array(8).fill("old"),widgets_values_named:{source_data:large,unrelated:"keep"}};
+8
View File
@@ -0,0 +1,8 @@
import assert from 'node:assert/strict';
globalThis.location=new URL('https://example.test/app');let calls=[];
globalThis.Image=class {set src(value){this.url=value;if(!value)return;calls.push(value);queueMicrotask(()=>calls.length<3?this.onerror?.():this.onload?.());}};
const {loadImage}=await import('../web/image_load.js');
const image=await loadImage('/view?filename=test.png',{retry:true,delays:[0,0]});assert.equal(calls.length,3);assert.match(image.url,/_adachi_retry=2/);
calls=[];await assert.rejects(loadImage('data:image/png;base64,invalid',{retry:true,delays:[0,0]}));assert.equal(calls.length,1);
calls=[];const c=new AbortController();c.abort();await assert.rejects(loadImage('/view?filename=test.png',{retry:true,signal:c.signal}),{name:'AbortError'});assert.equal(calls.length,0);
console.log('Fresh uploaded image loading: bounded retry, cache bypass, non-network image and cancellation passed');
+13
View File
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import {createPaintDraftProtection} from '../web/paint_draft.js';
const tick=()=>new Promise(r=>setTimeout(r,20));
let w={value:'data:old'},p={revision:1,drawing:false},checks=0,marks=0,errors=[],requests=[];
const protect=createPaintDraftProtection({paint:()=>p,widget:()=>w,graph:()=>({}),disposed:()=>false,persist:()=>true,fits:()=>{checks++;return false;},upload:data=>new Promise((resolve,reject)=>requests.push({data,resolve,reject})),changed:()=>marks++,onError:e=>errors.push(e.message)});
protect.schedule();await tick();assert.equal(requests.length,1);
protect.schedule();await tick();assert.equal(checks,1,'slow upload must not spin');
w.value='data:new';p.revision++;protect.schedule();await tick();assert.equal(requests.length,1);
requests[0].resolve('old.png');await tick();assert.equal(w.value,'data:new');assert.equal(requests.length,2,'new edit is uploaded after old request settles');
requests[1].resolve('new.png');await tick();assert.equal(w.value,'new.png');assert(marks>0);
p.drawing=true;w.value='data:stroke';p.revision++;const before=checks;protect.schedule();await tick();assert.equal(checks,before,'drawing must not spin');
p.drawing=false;protect.schedule();await tick();assert.equal(requests.length,3);requests[2].reject(Error('offline'));await tick();assert.equal(w.value,'data:stroke');assert.deepEqual(errors,['offline']);
protect.dispose();console.log('Paint draft: pending upload and stroke do not spin; stale upload, retry and offline retention passed');
+13 -1
View File
@@ -8,7 +8,7 @@ class Raster {
const decode = async data => new Raster(JSON.parse(data).pixels);
const source = name => ({src:name, width:4,height:3});
const context = prefix => ({mask:new Raster(prefix+"-mask"), baseMask:new Raster(prefix+"-base"),history:[{type:"brush",points:[{x:1,y:2}]}],future:[{type:"erase",points:[{x:2,y:1}]}],lastMask:prefix+"-serialized",dirty:false});
const widgetNames=["image","mask_data","source_data","edit_mode","crop_state","blend_data","layer_image"];
const widgetNames=["image","mask_data","source_data","edit_mode","crop_state","blend_data","layer_image","paint_data"];
function node() {
const n={properties:{},widgets:widgetNames.map(name=>({name,value:name})),graph:{setDirtyCanvas(){}}};
const e=n.adachiEditor={...context("repaint"),image:source("original"),ready:true,loadVersion:1,maskRevision:0,pan:{x:1,y:2},zoom:2,keepFit:true,view:"overlay",filename:{textContent:"original.png"},sizeLabel:{},embed:{checked:false},flush(){},schedule(){},updateButtons(){}};
@@ -18,6 +18,7 @@ function node() {
{
const n=node(),e=n.adachiEditor,c=n.adachiCrop;
n.adachiColorPaint={async prepareRestore(data){return data;},commitRestore(data){this.restored=data;}};
n.widgets.find(w=>w.name==="edit_mode").value="crop";
n.widgets.find(w=>w.name==="crop_state").value=JSON.stringify({locked:true,x:1,y:2,width:512,height:768,job:"job-a"});
const before=captureSourceState(n);
@@ -29,6 +30,7 @@ function node() {
assert.equal(c.contexts.blend.mask.pixels,"blend-mask");assert.equal(c.target,"mask");
assert.equal(e.history[0].points[0].x,1);assert.equal(e.future[0].type,"erase");
assert.equal(JSON.parse(n.widgets.find(w=>w.name==="crop_state").value).job,"job-a");
assert.equal(n.adachiColorPaint.restored,'paint_data');
assert.deepEqual(e.pan,{x:1,y:2});assert.equal(e.zoom,2);assert.equal(e.keepFit,true);
assert.notEqual(e.mask,before.contexts.mask.mask);
e.mask.pixels="changed-again";await restoreSourceState(n,before,{decode});assert.equal(e.mask.pixels,"repaint-mask");
@@ -43,9 +45,19 @@ function node() {
const n=node(),before=captureSourceState(n),old=n.adachiEditor.image;
await assert.rejects(restoreSourceState(n,before,{decode:async()=>{throw Error("broken PNG")}}),/broken PNG/);
assert.equal(n.adachiEditor.image,old);
n.adachiColorPaint={async prepareRestore(){throw Error('bad paint');},commitRestore(){throw Error('must not commit');}};
await assert.rejects(restoreSourceState(n,before,{decode}),/bad paint/);
assert.equal(n.adachiEditor.image,old,'paint decode failure preserves the current image');
delete n.adachiColorPaint;
await assert.rejects(restoreSourceState(n,before,{decode:async data=>{n.adachiEditor.loadVersion++;return decode(data)}}),/image changed/);
assert.equal(n.adachiEditor.image,old);
}
{
const n=node(),before=captureSourceState(n),old=n.adachiEditor.image;
n.adachiColorPaint={revision:1,async prepareRestore(){this.revision++;return null;},commitRestore(){throw Error('must not commit');}};
await assert.rejects(restoreSourceState(n,before,{decode}),/image changed/);
assert.equal(n.adachiEditor.image,old,'paint changes during history decode must not be overwritten');
}
{
const n=node(),h=new WorkspaceHistory(n,{maxEntries:3});h.checkpoint("original");
await h.transact("adopted",async()=>{n.adachiEditor.image=source("adopted");n.widgets[0].value="adopted.png"});
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import {WorkspaceBindings} from '../web/workspace_bindings.js';
class Element {
constructor(tag,doc){this.tagName=tag.toUpperCase();this.ownerDocument=doc;this.children=[];this.dataset={};this.isConnected=true;this.attributes={};}
append(...nodes){for(const node of nodes)node.parentElement=this;this.children.push(...nodes);}
replaceChildren(...nodes){this.children.forEach(child=>{child.isConnected=false;});this.children=[...nodes];}
setAttribute(name,value){this.attributes[name]=value;}
remove(){this.isConnected=false;}
contains(node){return this===node||this.children.some(child=>child.contains?.(node));}
querySelectorAll(tag){return this.children.flatMap(child=>[...(child.tagName===tag.toUpperCase()?[child]:[]),...child.querySelectorAll(tag)]);}
closest(){let node=this;while(node){if(node.tagName==='DETAILS'&&!node.open)return node;node=node.parentElement;}return null;}
focus(){this.ownerDocument.activeElement=this;}
setSelectionRange(start,end,direction){this.selectionStart=start;this.selectionEnd=end;this.selectionDirection=direction;}
}
const doc={createElement:tag=>new Element(tag,doc)};globalThis.document=doc;
const sampler={id:1,type:'AdachiWebUIInpaint',mode:0,properties:{},inputs:[],widgets:[]};
const controller={id:2,type:'AdachiGroupPresets',mode:0,properties:{}};
controller.adachiGroupPresets={model:{node:controller,data:{activeId:'one',presets:[{id:'one',name:'One'},{id:'two',name:'Two'}]},apply(id){this.data.activeId=id;}}};
const graph={_nodes:[sampler,controller],getNodeById:id=>graph._nodes.find(n=>String(n.id)===String(id)),links:{}};
let changed=0,checks=0;
const binding=new WorkspaceBindings({graph,sampler,markChanged:()=>changed++,onChange:()=>checks++});
const entry=(id,text)=>({id,node:{type:'CLIPTextEncode',widgets:[{name:'text',value:text}]},api:{class_type:'CLIPTextEncode',inputs:{text}},fields:['text']});
const positive=entry('p','positive original'),negative=entry('n','negative original');
const flow=(positives=[positive])=>({encoders:{positive:positives,negative:[negative]},modelNodes:[],clipNodes:[],vaeNodes:[],loras:[],refs:{},clipConsumers:[]});
binding.flow=flow();binding.samplerId='1';binding.createPanel();
const section=kind=>binding.root.children.find(x=>x.dataset.promptKind===kind);
const textarea=group=>group.children.flatMap(x=>x.children||[]).find(x=>x.tagName==='TEXTAREA');
assert.equal(section('positive').open,true);assert.equal(section('negative').open,true);
let positivePanel=section('positive');positivePanel.ontoggle();
assert.equal(changed,0,'initial native toggle does not dirty the workflow');
positivePanel.open=false;positivePanel.ontoggle();
assert.equal(changed,1);assert.equal(checks,0,'folding is cosmetic and does not trigger workflow validation');
assert.equal(sampler.properties.adachi_workspace_ui.promptOpen.positive,false);
assert.equal(section('negative').open,true,'fold states are independent');
const n=textarea(section('negative'));n.value='edited negative';n.oninput();n.setSelectionRange(3,7,'forward');n.scrollTop=12;n.focus();
assert.deepEqual(binding.getExecutionBinding().inputOverrides.n,{text:'edited negative'});
binding.render();assert.equal(section('positive').open,false);assert.equal(section('negative').open,true);
assert.equal(doc.activeElement,textarea(section('negative')),'asynchronous panel rendering retains the focused prompt');
assert.equal(doc.activeElement.selectionStart,3);assert.equal(doc.activeElement.selectionEnd,7);assert.equal(doc.activeElement.scrollTop,12);
positivePanel.open=true;positivePanel.ontoggle();
assert.equal(sampler.properties.adachi_workspace_ui.promptOpen.positive,false,'events from detached panels cannot override current state');
binding.selectProfile('two');assert.equal(section('positive').open,false);
section('negative').open=false;section('negative').ontoggle();
binding.selectProfile('one');binding.flow=flow();binding.samplerId='1';binding.render();
assert.equal(section('positive').open,false);assert.equal(section('negative').open,false);
assert.deepEqual(binding.getExecutionBinding().inputOverrides.n,{text:'edited negative'},'profile changes preserve prompt overrides');
const restoredSampler={...sampler,properties:JSON.parse(JSON.stringify(sampler.properties))};
const restoredGraph={_nodes:[restoredSampler],getNodeById:()=>restoredSampler,links:{}};
const restored=new WorkspaceBindings({graph:restoredGraph,sampler:restoredSampler});restored.flow=flow();restored.createPanel();
assert.equal(restored.root.children.find(x=>x.dataset.promptKind==='positive').open,false);
assert.equal(restored.root.children.find(x=>x.dataset.promptKind==='negative').open,false,'saved workflow retains both folds');
delete restoredSampler.properties.adachi_workspace_ui;
restored.flow=flow([positive,entry('p2','other')]);restored.render();
const multi=restored.root.children.find(x=>x.dataset.promptKind==='positive');
assert.equal(multi.open,false,'existing multi-encoder panels default to collapsed');
assert.equal(multi.children.filter(x=>x.tagName==='LABEL').length,2,'all encoder fields remain available');
console.log('Workspace prompt folding: independent states, cosmetic toggles, profile/workflow persistence, stale events and multi-encoder prompts pass');
+40
View File
@@ -0,0 +1,40 @@
import {T} from './i18n.js';
// A separate transparent image-space layer. It never mutates source Image
// objects held by the workspace's history and is persisted in paint_data.
export class ColorPaint {
constructor(editor,crop,widget,changed,imageUrl=x=>x){
Object.assign(this,{editor,crop,widget,changed,imageUrl});this.active=false;this.color='#ff8a32';this.undo=[];this.redo=[];this.revision=0;
this.layer=document.createElement('canvas');
const controls=document.createElement('div');controls.className='dd-color-paint';
this.button=document.createElement('button');this.button.type='button';this.button.textContent='✎';this.button.title=T('Paint image pixels; Alt-click picks a color; Ctrl+wheel changes brush size');this.button.setAttribute('aria-label',this.button.title);
this.button.onclick=e=>{e.preventDefault();e.stopPropagation();if(!this.widget){editor.status(T('The paint layer is unavailable; update and restart ComfyUI'),true);return;}this.active=!this.active;if(this.active && editor.view==='mask')editor.view='overlay';this.sync();editor.schedule();};
this.picker=document.createElement('input');this.picker.type='color';this.picker.value=this.color;this.picker.title=T('Brush color');this.picker.setAttribute('aria-label',this.picker.title);this.picker.oninput=()=>{this.color=this.picker.value;};
this.undoButton=document.createElement('button');this.undoButton.type='button';this.undoButton.textContent='↶';this.undoButton.title=T('Undo color painting');this.undoButton.onclick=()=>this.step(this.undo,this.redo);
this.redoButton=document.createElement('button');this.redoButton.type='button';this.redoButton.textContent='↷';this.redoButton.title=T('Redo color painting');this.redoButton.onclick=()=>this.step(this.redo,this.undo);
controls.append(this.button,this.picker,this.undoButton,this.redoButton);crop.bar.prepend(controls);this.controls=controls;
const draw=editor.options.drawSource,revision=editor.options.sceneRevision;
editor.options.drawSource=ctx=>{const done=draw?.(ctx);if(!done)ctx.drawImage(editor.image,0,0);if(this.layer.width&&!crop.previewOriginal)ctx.drawImage(this.layer,0,0);return true;};
editor.options.sceneRevision=()=>`${revision?.()||''}:paint:${this.revision}`;
const down=editor.pointerDown.bind(editor),move=editor.pointerMove.bind(editor),up=editor.pointerUp.bind(editor);
editor.pointerDown=e=>{if(this.active && this.restoring){e.preventDefault();e.stopPropagation();return;}if(!this.active||e.button!==0||editor.space||!editor.ready||crop.busy||crop.previewOriginal||(crop.mode==='crop'&&!crop.region?.locked))return down(e);
e.preventDefault();e.stopPropagation();const p=editor.point(e);if(e.altKey){this.pick(p);return;}
this.ensure();this.remember();this.redo=[];this.drawing={id:e.pointerId,last:p};editor.pointerId=e.pointerId;editor.canvas.setPointerCapture(e.pointerId);this.segment(p,p);this.sync();};
editor.pointerMove=e=>{if(!this.drawing||this.drawing.id!==e.pointerId)return move(e);e.preventDefault();e.stopPropagation();const events=e.getCoalescedEvents?.()||[];for(const event of events.length?events:[e]){const p=editor.point(event);this.segment(this.drawing.last,p);this.drawing.last=p;}editor.cursor=editor.point(e);editor.schedule();};
editor.pointerUp=e=>{if(!this.drawing||this.drawing.id!==e.pointerId)return up(e);e.preventDefault();e.stopPropagation();this.drawing=null;editor.pointerId=null;if(editor.canvas.hasPointerCapture(e.pointerId))editor.canvas.releasePointerCapture(e.pointerId);this.flush();this.sync();};
this.sync();
}
ensure(){const {image}=this.editor;if(!image)return;if(this.layer.width!==image.width||this.layer.height!==image.height){this.layer.width=image.width;this.layer.height=image.height;}}
segment(a,b){const ctx=this.layer.getContext('2d');ctx.save();const bounds=this.editor.options.paintBounds?.();if(bounds){ctx.beginPath();ctx.rect(bounds.x,bounds.y,bounds.width,bounds.height);ctx.clip();}ctx.strokeStyle=this.color;ctx.fillStyle=this.color;ctx.lineWidth=this.editor.brushSize;ctx.lineCap='round';ctx.lineJoin='round';ctx.globalAlpha=this.editor.brushOpacity/100;if(a.x===b.x&&a.y===b.y){ctx.beginPath();ctx.arc(a.x,a.y,ctx.lineWidth/2,0,Math.PI*2);ctx.fill();}else{ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.stroke();}ctx.restore();this.revision++;this.editor.schedule();}
pick(p){const c=document.createElement('canvas');c.width=c.height=1;const x=c.getContext('2d',{willReadFrequently:true});x.translate(-Math.floor(p.x),-Math.floor(p.y));this.editor.options.drawSource(x);const [r,g,b,a]=x.getImageData(0,0,1,1).data;if(a){this.color='#'+[r,g,b].map(n=>n.toString(16).padStart(2,'0')).join('');this.picker.value=this.color;}}
flush(){if(!this.widget||!this.layer.width)return;try{this.widget.value=this.layer.toDataURL('image/png');this.changed();}catch(error){this.editor.status(error.message,true);throw error;}}
async prepareRestore(data,width,height){if(!data)return null;const image=new Image();image.src=data.startsWith('data:')?data:this.imageUrl(data);await image.decode();if(image.width!==width||image.height!==height)throw Error(T('Paint layer dimensions do not match the source image'));return image;}
commitRestore(image,width,height){this.undo=[];this.redo=[];this.layer.width=width;this.layer.height=height;if(image)this.layer.getContext('2d').drawImage(image,0,0);this.revision++;this.editor.schedule();this.sync();}
async restore(data){const width=this.editor.image.width,height=this.editor.image.height,loadVersion=this.editor.loadVersion,revision=this.revision,version=this.restoreVersion=(this.restoreVersion||0)+1;this.restoring=true;try{const image=await this.prepareRestore(data,width,height);if(this.editor.destroyed||this.restoreVersion!==version||this.editor.loadVersion!==loadVersion||this.revision!==revision||this.widget?.value!==data)return false;this.commitRestore(image,width,height);return true;}finally{if(this.restoreVersion===version)this.restoring=false;}}
remember(){this.undo.push(this.layer.toDataURL('image/png'));let bytes=this.undo.reduce((n,s)=>n+s.length*2,0);while(this.undo.length>1&&(this.undo.length>20||bytes>64*1024*1024))bytes-=this.undo.shift().length*2;}
async step(from,to){if(this.drawing||this.restoring||!from.length)return;this.restoring=true;const current=this.layer.toDataURL('image/png'),data=from.pop(),image=new Image();image.src=data;try{await image.decode();to.push(current);const ctx=this.layer.getContext('2d');ctx.clearRect(0,0,this.layer.width,this.layer.height);ctx.drawImage(image,0,0);this.revision++;this.flush();this.editor.schedule();this.sync();}catch(e){from.push(data);this.editor.status(e.message,true);}finally{this.restoring=false;}}
clear(){this.undo=[];this.redo=[];this.layer.width=this.layer.height=0;if(this.widget)this.widget.value='';this.revision++;this.sync();}
destroy(){this.layer.width=this.layer.height=0;this.controls.remove();}
clearRegion(r){if(!this.layer.width)return;this.remember();this.redo=[];this.layer.getContext('2d').clearRect(r.x,r.y,r.width,r.height);this.revision++;this.flush();this.editor.schedule();this.sync();}
sync(){this.editor.stage.classList.toggle('dd-color-active',this.active);this.button.classList.toggle('dd-active',this.active);this.button.setAttribute('aria-pressed',String(this.active));this.picker.hidden=this.undoButton.hidden=this.redoButton.hidden=!this.active;this.undoButton.disabled=!this.undo.length;this.redoButton.disabled=!this.redo.length;this.editor.updateButtons?.();}
}
+36 -12
View File
@@ -1,6 +1,8 @@
/* Original hover-comparison UI, MIT licensed. */
/* SPDX-License-Identifier: GPL-3.0-only
* Original hover-comparison UI. */
import { T } from "./i18n.js";
import {loadPreviewPair} from './preview_images.js';
import {CompareViewport} from './compare_viewport.js';
const element = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
@@ -15,6 +17,7 @@ export class ImageCompareView {
this.originals = []; this.results = []; this.index = 0;
this.split = .5;
this.version = 0; this.ready = false;
this.viewport = new CompareViewport();this.workspaceZoom=false;this.displayMode='compare';
this.dimensionsLabel = element("span", "dd-dimensions");
const toolbar = element("div", "dd-row dd-compare-toolbar");
toolbar.append(element("span", "dd-compare-label", T("Result")));
@@ -31,8 +34,8 @@ export class ImageCompareView {
this.downloadButton.setAttribute("aria-label", this.downloadButton.title);
this.downloadButton.onclick = event => { event.preventDefault(); event.stopPropagation(); this.downloadLatest(); };
toolbar.append(this.downloadButton);
const fit = element("button", "", T("Fit")); fit.type = "button";
fit.onclick = event => { event.preventDefault(); event.stopPropagation(); this.schedule(); }; toolbar.append(fit);
const fit = element("button", "dd-compare-fit", T("Fit")); fit.type = "button";
fit.onclick = event => { event.preventDefault(); event.stopPropagation(); this.fitView(); }; toolbar.append(fit);
this.stage = element("div", "dd-stage dd-compare-stage");
this.canvas = document.createElement("canvas"); this.canvas.tabIndex = 0;
this.canvas.setAttribute("aria-label", T("Result left, original right; hover to compare"));
@@ -64,10 +67,12 @@ export class ImageCompareView {
this.root.append(this.stage, toolbar, adoption, this.message);
this.canvas.addEventListener("pointermove", event => this.move(event));
this.canvas.addEventListener("pointerdown", event => {
if(this.workspaceZoom && this.ready && event.button===1){event.preventDefault();event.stopPropagation();this.panDrag={id:event.pointerId,x:event.clientX,y:event.clientY};this.canvas.setPointerCapture(event.pointerId);return;}
if (!this.ready || event.button !== 0) return;
event.preventDefault(); event.stopPropagation(); this.canvas.focus({ preventScroll: true });
this.move(event);
});
for(const type of ['pointerup','pointercancel','lostpointercapture'])this.canvas.addEventListener(type,event=>{if(this.panDrag?.id===event.pointerId)this.panDrag=null;});
this.canvas.addEventListener("wheel", event => this.wheel(event), { passive: false });
this.canvas.addEventListener("keydown", event => {
if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
@@ -173,6 +178,7 @@ export class ImageCompareView {
}
}
if(!current()){for(const item of images||[])URL.revokeObjectURL(item.url);return;}
if(this.before && (this.before.width!==images[0].image.width || this.before.height!==images[0].image.height))this.viewport.fit();
for(const url of this.previewUrls||[])URL.revokeObjectURL(url);
this.previewUrls=images.map(item=>item.url);this.before=images[0].image;this.after=images[1].image;
this.ready=true;this.pendingBlobs=null;this.retryCount=0;this.empty.hidden=true;this.progressBox.hidden=true;
@@ -196,16 +202,22 @@ export class ImageCompareView {
if (!this.targetSelect.value) { this.status(T("Select an inline mask to adopt into"), true); return; }
const target = this.targetSelect.value, result = this.results[this.index];
this.busy = true; this.updateButton(); this.status(T("Setting the result as the new source image…"));
const progress = ({phase, loaded=0, total=0}={}) => {
const text = phase === 'download' ? T('Downloading result…') : phase === 'upload' ? T('Uploading result…') : phase === 'authenticated' ? T('Uploading with authentication…') : phase === 'processing' ? T('Upload complete, loading image…') : T('Preparing result…');
this.loading(text, phase === 'download' || phase === 'upload' ? [{loaded,total}] : undefined);
};
progress({phase:'prepare'});
const controller=this.adoptController=new AbortController();
try {
await this.options.onAdopt(target, result,{signal:controller.signal});
await this.options.onAdopt(target, result,{signal:controller.signal,onProgress:progress});
if (!this.destroyed) this.status(T("Adopted: back in the inline mask, keep painting to inpaint again"));
} catch (error) { if (!this.destroyed) this.status(error.message, true); }
finally {if(this.adoptController===controller)this.adoptController=null;this.busy = false; if (!this.destroyed) this.updateButton();}
finally {if(this.adoptController===controller)this.adoptController=null;this.busy = false; if (!this.destroyed) {this.progressBox.hidden=true;this.updateButton();}}
}
transform() {
const rect = this.canvas.getBoundingClientRect();
if(this.workspaceZoom && this.before)return this.viewport.transform(rect.width,rect.height,this.before.width,this.before.height);
const scale = this.before ? Math.min(rect.width / this.before.width, rect.height / this.before.height) : 1;
return { width: rect.width, height: rect.height, scale, x: (rect.width - (this.before?.width || 0) * scale) / 2, y: (rect.height - (this.before?.height || 0) * scale) / 2 };
}
@@ -214,11 +226,19 @@ export class ImageCompareView {
wheel(event) {
event.preventDefault(); event.stopPropagation();
if(this.workspaceZoom){
if(!this.ready)return;
const rect=this.canvas.getBoundingClientRect(),delta=event.deltaY*(event.deltaMode===1?16:event.deltaMode===2?rect.height:1);
this.viewport.zoomAt(rect.width,rect.height,this.before.width,this.before.height,event.clientX-rect.left,event.clientY-rect.top,delta);
if(this.displayMode==='compare')this.move(event);this.schedule();return;
}
this.options.onGraphWheel?.(event);
}
move(event) {
if (!this.ready) return;
if(this.panDrag && this.panDrag.id===event.pointerId){this.viewport.pan(event.clientX-this.panDrag.x,event.clientY-this.panDrag.y);this.panDrag.x=event.clientX;this.panDrag.y=event.clientY;this.schedule();return;}
if(this.displayMode!=='compare')return;
this.hovered = true;
this.split = Math.max(0, Math.min(1, this.position(event).x / this.before.width));
this.schedule();
@@ -226,43 +246,47 @@ export class ImageCompareView {
schedule() { if (!this.frame && !this.destroyed) this.frame = requestAnimationFrame(() => { this.frame = 0; this.render(); }); }
enableWorkspaceZoom(enabled) {this.workspaceZoom=!!enabled;this.panDrag=null;this.displayMode='compare';this.viewport.fit();this.lastPaint=null;this.schedule();}
setDisplayMode(mode) {this.displayMode=['compare','before','after'].includes(mode)?mode:'compare';this.lastPaint=null;this.schedule();}
fitView() {this.viewport.fit();this.lastPaint=null;this.schedule();}
render() {
const t = this.transform(), ratio = Math.min(devicePixelRatio || 1, 2);
if (!t.width || !t.height) return;
// Graph zoom changes the screen rect, not the amount of source detail.
// Never allocate a huge upscaled bitmap just to magnify existing pixels.
const rasterRatio = Math.min(ratio, this.ready ? 1 / t.scale : ratio,
const rasterRatio = Math.min(ratio, this.ready && !this.workspaceZoom ? 1 / t.scale : ratio,
8192 / t.width, 8192 / t.height, Math.sqrt(16 * 1024 * 1024 / (t.width * t.height)));
const width = Math.max(1, Math.round(t.width * rasterRatio)), height = Math.max(1, Math.round(t.height * rasterRatio));
if (this.canvas.width !== width || this.canvas.height !== height) { this.canvas.width = width; this.canvas.height = height; }
const ctx = this.canvas.getContext("2d");
ctx.setTransform(1, 0, 0, 1, 0, 0);
this.divider.hidden = !this.ready || !this.hovered;
this.divider.hidden = !this.ready || !this.hovered || this.displayMode!=='compare';
if (!this.ready) { ctx.clearRect(0, 0, width, height); this.lastPaint = null; return; }
const split = this.split * this.before.width;
const lineX = Math.round((t.x + split * t.scale) * rasterRatio) + .5;
if (this.hovered) {
if (this.hovered && this.displayMode==='compare') {
const screenScale = t.width / this.canvas.clientWidth || 1;
Object.assign(this.divider.style, {
left: `${(t.x + split * t.scale) / screenScale}px`, top: `${t.y / screenScale}px`,
height: `${this.before.height * t.scale / screenScale}px`, width: `${1 / screenScale}px`,
});
}
const key = [this.version, width, height, t.scale * rasterRatio, t.x * rasterRatio, t.y * rasterRatio].join(":");
const key = [this.version, this.displayMode, width, height, t.scale * rasterRatio, t.x * rasterRatio, t.y * rasterRatio].join(":");
const previous = this.lastPaint;
ctx.save();
// While hovering, pixels outside the old/new divider positions are
// unchanged. Clip the redraw to that strip rather than repainting all.
if (previous?.key === key && previous.hovered && this.hovered) {
if (this.displayMode==='compare' && previous?.key === key && previous.hovered && this.hovered) {
const left = Math.max(0, Math.floor(Math.min(previous.lineX, lineX)) - 2);
const right = Math.min(width, Math.ceil(Math.max(previous.lineX, lineX)) + 2);
ctx.beginPath(); ctx.rect(left, 0, right - left, height); ctx.clip();
}
ctx.clearRect(0, 0, width, height);
ctx.setTransform(t.scale * rasterRatio, 0, 0, t.scale * rasterRatio, t.x * rasterRatio, t.y * rasterRatio);
ctx.drawImage(this.after, 0, 0, this.before.width, this.before.height);
ctx.drawImage(this.displayMode==='before'?this.before:this.after, 0, 0, this.before.width, this.before.height);
this.lastPaint = { key, hovered: this.hovered, lineX };
if (!this.hovered) { ctx.restore(); return; }
if (!this.hovered || this.displayMode!=='compare') { ctx.restore(); return; }
ctx.save(); ctx.beginPath(); ctx.rect(split, 0, this.before.width - split, this.before.height); ctx.clip();
ctx.drawImage(this.before, 0, 0); ctx.restore();
ctx.restore();
+7 -5
View File
@@ -1,6 +1,6 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { ImageCompareView } from "./compare.js?v=stability1";
import { ImageCompareView } from "./compare.js?v=toolbar-layout-20261112";
import { readPreview } from "./preview_transport.js";
import { installComparisonRecovery } from "./result_recovery.js";
import {executionId,executionNode,graphNodeEntries} from "./workspace_flow.js";
@@ -8,7 +8,7 @@ import {upstreamNodes} from "./workbench_analysis.js";
import { appendComparison } from "./compare_history.js?v=0.5.8";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { T } from "./i18n.js";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
import { bindWidgetToNodeWidth, createWidgetHost } from "./widget_layout.js?v=stable-host-20261112";
function viewPath(image) {
if (image?.type === "adachi_memory") return `/adachi-inpaint/compare/${encodeURIComponent(image.filename || "")}`;
@@ -50,7 +50,8 @@ app.registerExtension({
async onAdopt(id, image, options={}) {
const graph=app.rootGraph||app.graph,target = executionNode(graph,id);
if (!target?.adachiUseResult) throw new Error(T("No inline mask node is available to receive the result"));
const blob = await readPreview(api.fetchApi.bind(api),viewPath(image),{...options,maxBytes:64*1024*1024});
options.onProgress?.({phase:'download'});
const blob = await readPreview(api.fetchApi.bind(api),viewPath(image),{...options,onProgress:p=>options.onProgress?.({phase:'download',...p}),maxBytes:64*1024*1024});
options.signal?.throwIfAborted();
if(graph!==(app.rootGraph||app.graph)||target!==executionNode(graph,id))throw Error(T("The workflow changed; check connections again"));
if (blob.size > 64 * 1024 * 1024) throw new Error(T("Result image exceeds 64 MiB; downscale it first"));
@@ -67,11 +68,12 @@ app.registerExtension({
});
node.adachiCompare = compare;
const stopWheelRouting = routeWheelOutsideImage(compare.root, null, () => false);
const widget = node.addDOMWidget("adachi_compare", "adachi_compare_view", compare.root, {
const widgetHost = createWidgetHost(compare.root);
const widget = node.addDOMWidget("adachi_compare", "adachi_compare_view", widgetHost, {
serialize: false, hideOnZoom: false, getMinHeight: () => 420, getMaxHeight: () => 4096,
});
widget.serialize = false;
bindWidgetToNodeWidth(node, widget, compare.root);
bindWidgetToNodeWidth(node, widget, widgetHost);
const executed = node.onExecuted;
node.onExecuted = function (output) {
const result = executed?.apply(this, arguments);
+22
View File
@@ -0,0 +1,22 @@
/* SPDX-License-Identifier: GPL-3.0-only */
// Shared viewport for sliding comparison and single-image modes. Coordinates
// are CSS pixels relative to the visible stage, not physical canvas pixels.
export class CompareViewport {
constructor() { this.fit(); }
fit() { this.zoom=1; this.panX=0; this.panY=0; }
transform(width,height,imageWidth,imageHeight) {
const base=Math.min(width/imageWidth,height/imageHeight),scale=base*this.zoom;
return {width,height,scale,x:(width-imageWidth*scale)/2+this.panX,y:(height-imageHeight*scale)/2+this.panY};
}
zoomAt(width,height,imageWidth,imageHeight,x,y,deltaY) {
if(!(width>0&&height>0&&imageWidth>0&&imageHeight>0))return;
const current=this.transform(width,height,imageWidth,imageHeight);
const next=Math.max(0.1,Math.min(16,this.zoom*Math.exp(-Math.max(-240,Math.min(240,deltaY))*.0015)));
const ratio=next/this.zoom;
const nextX=x-(x-current.x)*ratio,nextY=y-(y-current.y)*ratio;
this.zoom=next;
const centered=this.transform(width,height,imageWidth,imageHeight);
this.panX+=nextX-centered.x;this.panY+=nextY-centered.y;
}
pan(dx,dy) {this.panX+=dx;this.panY+=dy;}
}
+9 -11
View File
@@ -1,6 +1,6 @@
// Probe only our own temporary key. Existing ComfyUI drafts are never edited or deleted.
export const inlineWidgetNames = [
"image", "mask_data", "source_data", "edit_mode", "crop_state", "blend_data", "layer_image",
"image", "mask_data", "source_data", "edit_mode", "crop_state", "blend_data", "layer_image", "paint_data",
];
// Some extensions also serialize a named snapshot. Keep it in step with the
@@ -35,24 +35,22 @@ export async function prepareInlineDraft(graph, { fits, upload }) {
node.widgets_values_named[name] = node.widgets_values[index];
}
}
const candidates = nodes.filter((node) => {
const embedded = node.widgets_values?.[2] ?? node.widgets_values_named?.source_data;
return typeof embedded === "string" && embedded.startsWith("data:") && embedded.length >= 256 * 1024;
}).sort((a, b) => (b.widgets_values?.[2] ?? b.widgets_values_named.source_data).length - (a.widgets_values?.[2] ?? a.widgets_values_named.source_data).length);
const candidates = nodes.flatMap(node => [[2,"source_data"],[7,"paint_data"]].map(([index,name]) => ({node,index,name,embedded:node.widgets_values?.[index] ?? node.widgets_values_named?.[name]})))
.filter(({embedded})=>typeof embedded === "string" && embedded.startsWith("data:") && embedded.length >= 256 * 1024)
.sort((a,b)=>b.embedded.length-a.embedded.length);
let converted = 0;
for (const node of candidates) {
for (const {node,index,name,embedded} of candidates) {
if (fits(graph) !== false) break;
const embedded = node.widgets_values?.[2] ?? node.widgets_values_named.source_data;
const filename = await upload(embedded);
if (typeof filename !== "string" || !filename) throw new Error("Source upload returned no filename");
// Commit only after a successful exact upload. All masks and layers stay intact.
if (Array.isArray(node.widgets_values)) {
node.widgets_values[0] = filename;
node.widgets_values[2] = "";
if(name === "source_data") node.widgets_values[0] = filename;
node.widgets_values[index] = name === "source_data" ? "" : filename;
}
if (node.widgets_values_named) {
node.widgets_values_named.image = filename;
node.widgets_values_named.source_data = "";
if(name === "source_data") node.widgets_values_named.image = filename;
node.widgets_values_named[name] = name === "source_data" ? "" : filename;
}
converted++;
}
+7
View File
@@ -26,8 +26,14 @@
.dd-inpaint input[type="range"] { display: block; width: 100%; min-width: 0; height: 18px; margin: 3px 0 0; accent-color: #f3b06a; cursor: pointer; }
.dd-inpaint .dd-stage { position: relative; flex: 1 1 290px; min-height: 170px; overflow: hidden; border-top: 1px solid var(--dd-line); border-bottom: 1px solid var(--dd-line); background-color: #161a20; background-image: linear-gradient(45deg, #222831 25%, transparent 25%), linear-gradient(-45deg, #222831 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #222831 75%), linear-gradient(-45deg, transparent 75%, #222831 75%); background-size: 20px 20px; background-position: 0 0, 0 10px, 10px -10px, -10px 0; }
.dd-inpaint .dd-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; cursor: crosshair; }
.dd-inpaint .dd-color-paint {display:flex;flex:none;gap:3px;align-items:center;}
.dd-inpaint .dd-color-paint button {min-width:26px;padding:3px 6px;}
.dd-inpaint .dd-color-paint input[type=color] {width:26px;height:25px;padding:1px;border:0;background:transparent;cursor:pointer;}
.dd-inpaint .dd-color-paint [hidden] {display:none;}
.dd-inpaint .dd-stage.dd-color-active canvas {cursor:crosshair;}
.dd-inpaint .dd-stage.dd-pan canvas { cursor: grab; }
.dd-inpaint .dd-stage.dd-erasing canvas { cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 32 32'%3E%3Cpath d='M6 19 18 7a3 3 0 0 1 4 0l5 5a3 3 0 0 1 0 4L16 27h-5l-5-5a2 2 0 0 1 0-3Z' fill='%23ffb3c0' stroke='%23151a22' stroke-width='3' stroke-linejoin='round'/%3E%3Cpath d='m7 19 7-7 10 10-5 5h-8l-4-4a3 3 0 0 1 0-4Z' fill='%23fff4f6'/%3E%3Cpath d='m13 13 10 10M11 28h17' fill='none' stroke='%23151a22' stroke-width='2'/%3E%3C/svg%3E") 16 16, crosshair; }
.dd-inpaint .dd-stage.dd-color-active.dd-erasing canvas {cursor:crosshair;}
.dd-inpaint .dd-stage.dd-drop { outline: 3px solid var(--dd-accent); outline-offset: -3px; }
.dd-inpaint .dd-empty { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; pointer-events: none; gap: 6px; color: var(--dd-muted); padding: 15px; text-align: center; }
.dd-inpaint .dd-empty[hidden] { display: none; }
@@ -96,6 +102,7 @@
.dd-comparer .dd-compare-stage { border: 0; }
.dd-comparer .dd-compare-toolbar { padding-top: 5px; padding-bottom: 2px; }
.dd-comparer .dd-compare-toolbar .dd-dimensions { margin-left: 5px; }
.adachi-workspace .dd-comparer .dd-compare-fit { display: none; }
.dd-comparer .dd-adoption { padding-top: 3px; padding-bottom: 5px; }
.dd-comparer .dd-compare-captions { position: absolute; top: 8px; left: 8px; right: 8px; display: flex; justify-content: space-between; pointer-events: none; }
.dd-comparer .dd-compare-captions span { background: #171d27cc; color: #f1e3cf; padding: 3px 7px; border-radius: 3px; font-size: 11px; }
+12 -14
View File
@@ -1,6 +1,8 @@
/* Original implementation, MIT licensed. No WebUI source is bundled. */
/* SPDX-License-Identifier: GPL-3.0-only
* Original implementation. No WebUI source is bundled. */
import { registerImageWheel } from "./wheel_gestures.js";
import { T } from "./i18n.js";
import { loadImage } from "./image_load.js";
const el = (tag, cls, text) => {
const node = document.createElement(tag);
if (cls) node.className = cls;
@@ -8,12 +10,6 @@ const el = (tag, cls, text) => {
return node;
};
const makeCanvas = () => document.createElement("canvas");
const loadImage = (url) => new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(T("Cannot load image")));
image.src = url;
});
export class InlineMaskEditor {
constructor(options = {}) {
@@ -46,7 +42,7 @@ export class InlineMaskEditor {
// this canvas at window capture so image gestures stay inside it.
this.stopWheelCapture = registerImageWheel(this.canvas, () => this.ready, event => this.wheel(event), options.onGraphWheel);
this.modifierDown = event => {
if (event.key !== "Alt" || !this.ready || !this.canvas.matches(":hover")) return;
if (event.key !== "Alt" || this.colorPaint?.active || !this.ready || !this.canvas.matches(":hover")) return;
event.preventDefault(); this.altErase = true; this.updateButtons(); this.schedule();
};
this.modifierUp = event => {
@@ -278,13 +274,13 @@ export class InlineMaskEditor {
}
}
async setImage(url, maskData = "", name = "") {
async setImage(url, maskData = "", name = "", options = {}) {
this.stroke = null; this.pointerId = null;
const version = ++this.loadVersion;
const wasReady=this.ready;
this.ready = false;
try {
const [image, loadedMask] = await Promise.all([loadImage(url), maskData ? loadImage(maskData) : Promise.resolve(null)]);
const [image, loadedMask] = await Promise.all([loadImage(url,options), maskData ? loadImage(maskData) : Promise.resolve(null)]);
if (version !== this.loadVersion || this.destroyed) return;
if (image.width * image.height > 32_000_000 || Math.max(image.width, image.height) > 16384) {
throw new Error(T("Image is too large; keep it under 32 MP and 16384 px per side"));
@@ -325,9 +321,9 @@ export class InlineMaskEditor {
}
}
async replaceSource(url, name = "") {
async replaceSource(url, name = "", options = {}) {
const version = ++this.loadVersion;
const image = await loadImage(url);
const image = await loadImage(url,options);
if (version !== this.loadVersion || this.destroyed) return false;
// A different size cannot keep the painted mask; report it so the caller
// can fall back to a fresh load instead of failing the upload.
@@ -361,6 +357,7 @@ export class InlineMaskEditor {
}
setTool(tool) {
if (this.colorPaint?.active) { this.colorPaint.active = false; this.colorPaint.sync(); }
this.tool = tool;
if (this.stage) this.stage.classList.toggle("dd-pan", tool === "pan");
this.updateButtons();
@@ -372,7 +369,7 @@ export class InlineMaskEditor {
if (this.downloadButton) this.downloadButton.disabled = !this.image;
this.stage?.classList.toggle("dd-erasing", !!(this.altErase || this.tool === "erase" || this.stroke?.type === "erase"));
for (const [name, button] of Object.entries(this.toolButtons || {})) {
const active = name === (this.altErase ? "erase" : this.tool);
const active = !this.colorPaint?.active && name === (this.altErase ? "erase" : this.tool);
button.classList.toggle("dd-active", active);
button.setAttribute("aria-pressed", String(active));
}
@@ -422,7 +419,8 @@ export class InlineMaskEditor {
pointerMove(event) {
if (!this.ready) return;
if (this.altErase !== !!event.altKey) { this.altErase = !!event.altKey; this.updateButtons(); }
const eraseModifier=!!event.altKey && !this.colorPaint?.active;
if (this.altErase !== eraseModifier) { this.altErase = eraseModifier; this.updateButtons(); }
this.cursor = this.point(event);
if (event.pointerId === this.pointerId) {
event.preventDefault(); event.stopPropagation();
+11
View File
@@ -88,6 +88,7 @@ const ZH = {
"Use the workflow Refiner (must be compatible with the main model)": "使用工作流中的 Refiner(需与主模型兼容)",
"Positive prompt": "正面提示词",
"Negative prompt": "负面提示词",
"Fit comparison image": "适应对比图片",
"Controlled by the original workflow": "由原工作流控制",
"Reading connections…": "正在读取连接…",
"Encoders are identified from the execution graph; unrecognized conditioning stays controlled by the original workflow": "通过实际执行图查找编码器;无法明确识别的条件保留原工作流",
@@ -151,6 +152,9 @@ const ZH = {
"Reload comparison images": "重新加载对比图片",
"Image upload progress": "图片上传进度",
"Uploading image": "正在上传图片",
"Downloading result…": "正在下载结果…",
"Uploading result…": "正在上传结果…",
"Preparing result…": "正在准备结果…",
"Uploading image…": "正在上传图片…",
"Upload complete, loading image…": "上传完成,正在加载图片…",
"Uploading with authentication…": "正在通过身份验证上传…",
@@ -243,6 +247,13 @@ const ZH = {
"Pan": "移动",
"Pan (H), or hold Space": "移动 (H),或按住空格",
"Brush size": "笔径",
"Paint image pixels; Alt-click picks a color; Ctrl+wheel changes brush size": "画笔:Alt 加左键吸取颜色,Ctrl 加滚轮调整笔径",
"Brush color": "画笔颜色",
"Undo color painting": "撤销画笔",
"Redo color painting": "重做画笔",
"Paint layer dimensions do not match the source image": "画笔图层尺寸与原图不一致",
"Paint layer could not be saved as an input file; embedded data was retained": "无法将画笔图层保存到输入目录,已保留嵌入数据",
"The paint layer is unavailable; update and restart ComfyUI": "画笔图层不可用,请更新并重启 ComfyUI",
"Hardness": "硬度",
"Brush opacity": "笔触",
"Overlay": "叠加",
+24
View File
@@ -0,0 +1,24 @@
import {T} from './i18n.js';
// Retry only freshly uploaded same-origin view URLs. Local files, data URLs and
// restored inputs keep their original behavior. Each retry bypasses cached 404s.
export async function loadImage(url,{retry=false,signal,timeoutMs=15000,delays=[300,900]}={}) {
const parsed=new URL(url,location.href);
const canRetry=retry && parsed.origin===location.origin && /\/view$/.test(parsed.pathname);
const attempts=canRetry?delays.length+1:1;
for(let i=0;i<attempts;i++){
signal?.throwIfAborted();
if(i){await new Promise((resolve,reject)=>{const timer=setTimeout(done,delays[i-1]);function done(){signal?.removeEventListener('abort',abort);resolve();}function abort(){clearTimeout(timer);reject(new DOMException('Aborted','AbortError'));}signal?.addEventListener('abort',abort,{once:true});});}
const image=new Image();
try{
await new Promise((resolve,reject)=>{
const finish=fn=>value=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);image.onload=image.onerror=null;fn(value);};
const success=finish(resolve),failure=finish(reject),abort=()=>{image.src='';failure(new DOMException('Aborted','AbortError'));};
const timer=setTimeout(()=>failure(new Error(T('Cannot load image'))),timeoutMs);
image.onload=success;image.onerror=()=>failure(new Error(T('Cannot load image')));signal?.addEventListener('abort',abort,{once:true});
const request=new URL(parsed);if(i)request.searchParams.set('_adachi_retry',String(i));image.src=request.href;
});
return image;
}catch(error){if(error.name==='AbortError'||i===attempts-1)throw error;}
}
}
+23 -4
View File
@@ -1,7 +1,7 @@
import { CROP_PRESETS, cropAt } from "./crop_geometry.js?v=0.6.2";
import { T } from "./i18n.js";
import { syncCropDimensions, syncCurrentDimensions } from "./parameter_bridge.js?v=0.8.2";
import { installResultAdoption } from "./result_adoption.js?v=stability1";
import { installResultAdoption } from "./result_adoption.js?v=2.1.0";
const fields = ["mask", "baseMask", "history", "future", "lastMask", "dirty"];
const canvas = (w, h) =>
@@ -628,9 +628,26 @@ export class InlineCropController {
this.editor.flush();
this.busy = true;
this.sync();
// Releasing an adopted crop must first preserve the visible composite on
// the server. Show its real upload progress, particularly on remote hosts;
// do not unlock or discard the layer if that upload fails.
const e = this.editor;
const version = e.uploadUiVersion = (e.uploadUiVersion || 0) + 1;
const progress = ({phase, loaded = 0, total = 0}) => {
if (version !== e.uploadUiVersion || e.destroyed) return;
e.uploadIndicator.hidden = false;
if (phase === "upload" && total > 0) {
const percent = Math.max(0, Math.min(100, Math.round(100 * loaded / total)));
e.uploadLabel.textContent = `${T("Uploading image")} ${percent}%`;
e.uploadBar.value = percent;
} else {
e.uploadLabel.textContent = T(phase === "processing" ? "Upload complete, loading image…" : phase === "authenticated" ? "Uploading with authentication…" : "Uploading image…");
e.uploadBar.removeAttribute("value");
}
};
progress({phase:"upload"});
try {
const e = this.editor,
c = canvas(e.image.width, e.image.height);
const c = canvas(e.image.width, e.image.height);
this.drawSource(c.getContext("2d"));
const blob = await new Promise((resolve) =>
c.toBlob(resolve, "image/png"),
@@ -641,8 +658,9 @@ export class InlineCropController {
new File([blob], `merged-${crypto.randomUUID()}.png`, {
type: "image/png",
}),
{ preserveMask: true, preserveCrop: true },
{ preserveMask: true, preserveCrop: true, onProgress: progress },
);
e.status("");
this.layer = null;
this.widgets.layer_image.value = "";
this.widgets.blend_data.value = "";
@@ -650,6 +668,7 @@ export class InlineCropController {
this.contexts.blend = null;
this.state({ ...this.region, job: crypto.randomUUID() });
} finally {
if (version === e.uploadUiVersion && !e.destroyed) e.uploadIndicator.hidden = true;
this.busy = false;
this.sync();
this.editor.schedule();
+31 -19
View File
@@ -1,19 +1,21 @@
import { app } from "../../scripts/app.js";
import { api } from "../../scripts/api.js";
import { InlineMaskEditor } from "./editor.js?v=stability1";
import { installInpaintWorkbench } from "./inpaint_node.js?v=2.0.0";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
import { InlineMaskEditor } from "./editor.js?v=2.1.0";
import { installInpaintWorkbench } from "./inpaint_node.js?v=toolbar-layout-20261112";
import { bindWidgetToNodeWidth, createWidgetHost } from "./widget_layout.js?v=stable-host-20261112";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { InlineCropController } from "./inline_crop.js?v=stability1";
import { canStoreDraft, prepareInlineDraft, syncInlineWidgets } from "./draft_guard.js?v=0.6.3";
import { InlineCropController } from "./inline_crop.js?v=2.1.0";
import { canStoreDraft, prepareInlineDraft, syncInlineWidgets } from "./draft_guard.js?v=2.1.0";
import { T } from "./i18n.js";
import { removeLegacyFeedback } from "./legacy_feedback.js?v=0.6.4";
import { uploadImageWithProgress } from "./upload_transport.js?v=stability1";
import { ColorPaint } from "./color_paint.js?v=toolbar-layout-20261112";
import { createPaintDraftProtection } from "./paint_draft.js";
const styleId = "adachi-inpaint-style";
const stylesheet = document.getElementById(styleId) || document.createElement("link");
stylesheet.id = styleId; stylesheet.rel = "stylesheet";
stylesheet.href = new URL("./editor.css?v=workspace-network2", import.meta.url).href;
stylesheet.href = new URL("./editor.css?v=toolbar-layout-20261112", import.meta.url).href;
if (!stylesheet.isConnected) document.head.append(stylesheet);
function hide(widget) {
@@ -71,7 +73,7 @@ app.registerExtension({
const count = await prepareInlineDraft(graphData, {
fits: graph => canStoreDraft(graph, options), upload: uploadEmbeddedSource,
});
if (count) console.info(`[Adachi Inpaint] Saved ${count} oversized embedded source(s) as exact input files before workflow draft initialization. Masks and layers were preserved.`);
if (count) console.info(`[Adachi Inpaint] Saved ${count} oversized embedded source or paint layer(s) as input files before workflow draft initialization.`);
} catch (error) {
console.warn("[Adachi Inpaint] Draft preflight failed; embedded source data was retained.", error);
}
@@ -93,8 +95,8 @@ app.registerExtension({
node.graph?.setDirtyCanvas(true, true);
};
const find = name => node.widgets?.find(widget => widget.name === name);
const imageWidget = find("image"), maskWidget = find("mask_data"), sourceWidget = find("source_data");
for (const widget of [imageWidget, maskWidget, sourceWidget]) hide(widget);
const imageWidget = find("image"), maskWidget = find("mask_data"), sourceWidget = find("source_data"), paintWidget=find("paint_data");
for (const widget of [imageWidget, maskWidget, sourceWidget, paintWidget]) hide(widget);
let crop;
let draftRecovery = null;
let draftNoticeTimer = 0;
@@ -121,8 +123,8 @@ app.registerExtension({
if (!embedded && !filename) return;
restoring = true;
editor.embed.checked = !!embedded;
try { crop?.activate("mask"); await editor.setImage(embedded || imageUrl(filename), maskWidget?.value || "", embedded ? T("Embedded image") : filename); await crop?.restore(); }
catch (error) { console.warn("[Adachi Inpaint]", error); }
try { crop?.activate("mask"); await editor.setImage(embedded || imageUrl(filename), maskWidget?.value || "", embedded ? T("Embedded image") : filename); await crop?.restore(); await node.adachiColorPaint?.restore(paintWidget?.value || ""); }
catch (error) { console.warn("[Adachi Inpaint]", error); editor.status(error.message, true); }
finally { restoring = false; }
};
const editor = new InlineMaskEditor({
@@ -148,13 +150,14 @@ app.registerExtension({
crop?.activate("mask");
let kept = false;
if (preserveMask && editor.ready) {
kept = await editor.replaceSource(imageUrl(name), file.name);
kept = await editor.replaceSource(imageUrl(name), file.name, {retry:true,signal});
if (version !== uploadVersion || editor.destroyed) return;
// A different-sized replacement cannot reuse the old mask.
// Actually load it as a fresh image before clearing state.
if (!kept) await editor.setImage(imageUrl(name), "", file.name);
} else await editor.setImage(imageUrl(name), "", file.name);
if (!kept) await editor.setImage(imageUrl(name), "", file.name, {retry:true,signal});
} else await editor.setImage(imageUrl(name), "", file.name, {retry:true,signal});
if (version !== uploadVersion || editor.destroyed) return;
if (!preserveCrop) node.adachiColorPaint?.clear();
imageWidget.value = name;
maskWidget.value = kept ? editor.flush() : "";
sourceWidget.value = editor.embed.checked ? editor.sourceData() : "";
@@ -198,22 +201,28 @@ app.registerExtension({
crop = new InlineCropController(node, editor, {find, hide, changed:markChanged, imageUrl, beforeModeChange});
node.adachiCrop = crop;
node.adachiEditor = editor;
const paintDraft=createPaintDraftProtection({paint:()=>node.adachiColorPaint,widget:()=>paintWidget,graph:()=>app.rootGraph||node.graph,disposed:()=>editor.destroyed||!node.graph,persist:()=>app.ui.settings.getSettingValue('Comfy.Workflow.Persist')!==false,fits:graph=>canStoreDraft(graph.serialize(),draftOptions()),upload:uploadEmbeddedSource,changed:markChanged,onError:error=>editor.status(`${T("Paint layer could not be saved as an input file; embedded data was retained")}: ${error.message}`,true)});
node.adachiColorPaint = new ColorPaint(editor,crop,paintWidget,paintDraft.schedule,imageUrl);
editor.colorPaint=node.adachiColorPaint;
const acceptCrop=crop.accept.bind(crop);
crop.accept=async(...args)=>{const old=crop.widgets.layer_image.value,region={...crop.region};const result=await acceptCrop(...args);if(crop.widgets.layer_image.value && crop.widgets.layer_image.value!==old)node.adachiColorPaint.clearRegion(region);return result;};
const stopWheelRouting = routeWheelOutsideImage(editor.root, editor.canvas, () => editor.ready);
node.adachiUseResult = async (file, origin, options={}) => {
if (editor.destroyed) throw new Error(T("The inline mask node was removed"));
if (crop.mode === "crop") return node.adachiAdoptCrop(file, origin, options);
await editor.options.onUpload(file, { preserveMask: true, signal:options.signal });
await editor.options.onUpload(file, { preserveMask: true, signal:options.signal, onProgress:options.onProgress });
editor.view = "overlay"; editor.updateButtons(); editor.schedule();
editor.status(T("Adopted the result as the new source image; the mask and undo history were preserved"));
};
const domWidget = node.addDOMWidget("adachi_canvas", "adachi_inline_mask", editor.root, {
const widgetHost = createWidgetHost(editor.root);
const domWidget = node.addDOMWidget("adachi_canvas", "adachi_inline_mask", widgetHost, {
serialize: false,
hideOnZoom: false,
getMinHeight: () => 170,
getMaxHeight: () => 4096,
});
domWidget.serialize = false;
bindWidgetToNodeWidth(node, domWidget, editor.root);
bindWidgetToNodeWidth(node, domWidget, widgetHost);
// Leave computeLayoutSize in charge: a fixed computeSize height
// prevents the DOM canvas from growing with the node vertically.
const resizeHandle = document.createElement("div");
@@ -260,17 +269,20 @@ app.registerExtension({
const previousSerialize = maskWidget.serializeValue;
maskWidget.serializeValue = function () {
editor.flush();
if(node.adachiColorPaint.drawing) node.adachiColorPaint.flush();
return previousSerialize ? previousSerialize.apply(this, arguments) : maskWidget.value;
};
const previousSerializeNode = node.onSerialize;
node.onSerialize = function (data) {
const result = previousSerializeNode?.apply(this, arguments);
editor.flush();
if(node.adachiColorPaint.drawing) node.adachiColorPaint.flush();
if (data) syncInlineWidgets(data, node.widgets);
return result;
};
const previousQueued = maskWidget.beforeQueued;
maskWidget.beforeQueued = function () { editor.flush(); return previousQueued?.apply(this, arguments); };
maskWidget.beforeQueued = function () { editor.flush(); if(node.adachiColorPaint.drawing) node.adachiColorPaint.flush(); return previousQueued?.apply(this, arguments); };
if(paintWidget){const previousPaintSerialize=paintWidget.serializeValue;paintWidget.serializeValue=function(){if(node.adachiColorPaint.drawing)node.adachiColorPaint.flush();return previousPaintSerialize?previousPaintSerialize.apply(this,arguments):paintWidget.value;};}
const previousConfigure = node.onConfigure;
node.onConfigure = function () {
const configured = previousConfigure?.apply(this, arguments);
@@ -281,7 +293,7 @@ app.registerExtension({
const previousImageCallback = imageWidget.callback;
imageWidget.callback = function () { previousImageCallback?.apply(this, arguments); restore(); };
const previousRemoved = node.onRemoved;
node.onRemoved = function () { node.adachiWorkspaceHistory?.clear();node.adachiWorkspaceHistory=null;clearTimeout(draftNoticeTimer); cancelAnimationFrame(restoreFrame); cancelAnimationFrame(fitFrame); stopWheelRouting(); crop.destroy(); editor.destroy(); return previousRemoved?.apply(this, arguments); };
node.onRemoved = function () { node.adachiWorkspaceHistory?.clear();node.adachiWorkspaceHistory=null;clearTimeout(draftNoticeTimer);paintDraft.dispose(); cancelAnimationFrame(restoreFrame); cancelAnimationFrame(fitFrame); stopWheelRouting(); node.adachiColorPaint.destroy();crop.destroy(); editor.destroy(); return previousRemoved?.apply(this, arguments); };
node.setSize([Math.max(380, node.size[0]), Math.max(480, node.size[1])]);
restoreFrame = requestAnimationFrame(restore);
return result;
+5 -4
View File
@@ -1,9 +1,9 @@
import { app } from "../../scripts/app.js";
import { InpaintParameterPanel } from "./inpaint_panel.js?v=adachi-name";
import { InpaintParameterPanel } from "./inpaint_panel.js?v=toolbar-layout-20261112";
import { connectedEditor, connectedSamplers, relocateWidgets, syncCurrentDimensions } from "./parameter_bridge.js?v=0.8.2";
import { routeWheelOutsideImage } from "./wheel_routing.js";
import { T } from "./i18n.js";
import { bindWidgetToNodeWidth } from "./widget_layout.js?v=0.5.5";
import { bindWidgetToNodeWidth, createWidgetHost } from "./widget_layout.js?v=stable-host-20261112";
export function installInpaintWorkbench(nodeType) {
const created = nodeType.prototype.onNodeCreated;
@@ -81,11 +81,12 @@ export function installInpaintWorkbench(nodeType) {
root.append(panel.root);
node.adachiParameters = panel; node.adachiWorkbench = { root, tools, fold, actionHost, get source() { return source; } };
const stopWheelRouting = routeWheelOutsideImage(root, null, () => false);
const widget = node.addDOMWidget("adachi_workbench", "adachi_inpaint_controls", root, {
const widgetHost = createWidgetHost(root);
const widget = node.addDOMWidget("adachi_workbench", "adachi_inpaint_controls", widgetHost, {
serialize: false, hideOnZoom: false, getMinHeight: contentHeight, getMaxHeight: () => Math.max(4096, contentHeight()),
});
widget.serialize = false;
bindWidgetToNodeWidth(node, widget, root);
bindWidgetToNodeWidth(node, widget, widgetHost);
const observer = new ResizeObserver(scheduleSize); observer.observe(tools); observer.observe(fold); observer.observe(actionHost);
const configured = node.onConfigure;
node.onConfigure = function () {
+3 -3
View File
@@ -58,7 +58,7 @@ export class InpaintParameterPanel {
enum(parent, name, label, pills = false) {
const widget = this.widget(name); if (!widget) return;
label = T(label);
const wrapper = el("div", "dd-param-field"); wrapper.append(el("span", "dd-param-label", label));
const wrapper = el("div", "dd-param-field"); wrapper.dataset.adachiParam = name; wrapper.append(el("span", "dd-param-label", label));
let values = widget.options?.values; if (typeof values === "function") values = values();
if (!Array.isArray(values)) values = Object.keys(choices[name] || {});
if (!values.includes(widget.value)) values = [widget.value, ...values];
@@ -83,7 +83,7 @@ export class InpaintParameterPanel {
const widget = this.widget(name); if (!widget) return;
label = T(label);
min ??= widget.options?.min ?? 0; max ??= widget.options?.max ?? 1000000;
const wrapper = el("label", "dd-param-field"); const row = el("span", "dd-param-number-label"); row.append(el("span", "", label));
const wrapper = el("label", "dd-param-field"); wrapper.dataset.adachiParam = name; const row = el("span", "dd-param-number-label"); row.append(el("span", "", label));
const input = el("input"); input.type = "number"; input.min = min; input.max = max; input.step = step; input.setAttribute("aria-label", label); row.append(input); wrapper.append(row);
let slider;
const update = raw => {
@@ -120,7 +120,7 @@ export class InpaintParameterPanel {
this.body.append(this.maskSettings);
this.options.onMaskSettings?.(this.maskSettings);
this.enum(this.body, "area", "Sampling area", true);
const resolution = el("div", "dd-param-resolution"); resolution.append(el("span", "dd-param-label", T("Sampling resolution")));
const resolution = el("div", "dd-param-resolution"); resolution.dataset.adachiParam = "resolution"; resolution.append(el("span", "dd-param-label", T("Sampling resolution")));
this.sourceSize = el("button", "", T("Use source size")); this.sourceSize.type = "button";
this.sourceSize.title = T("Fill width and height from the current source image; aligned to 8 px and clamped to 64-8192 px");
this.sourceSize.onclick = () => {
+26
View File
@@ -0,0 +1,26 @@
// A pending upload owns the retry: new edits are checked once it settles.
// In particular, never poll a slow connection or an unfinished pointer stroke.
export function createPaintDraftProtection({paint,widget,graph,disposed,persist,fits,upload,changed,onError}) {
let timer=0, pending=false, stopped=false;
const schedule=()=>{
if(stopped)return;
clearTimeout(timer);
timer=setTimeout(run,0);
};
const run=async()=>{
timer=0;
if(stopped || disposed() || paint()?.drawing || pending)return;
const data=widget()?.value||'',revision=paint()?.revision;
try {
if(!data.startsWith('data:') || !persist() || fits(graph())!==false){changed();return;}
pending=true;
const name=await upload(data);
if(!stopped && !disposed() && widget()?.value===data && paint()?.revision===revision){widget().value=name;changed();}
} catch(error) {if(!stopped && !disposed())onError(error);}
finally {
pending=false;
if(!stopped && !disposed() && (widget()?.value!==data || paint()?.revision!==revision))schedule();
}
};
return {schedule,dispose(){stopped=true;clearTimeout(timer);}};
}
+2 -2
View File
@@ -17,7 +17,7 @@ export function installResultAdoption(node, crop) {
removeLegacyInput();
return result;
};
node.adachiAdoptCrop = async (file, origin, {signal}={}) => {
node.adachiAdoptCrop = async (file, origin, {signal,onProgress}={}) => {
const editor = crop.editor, region = crop.region;
if (!editor.ready || !region.locked) throw Error(T("Select a crop region before adopting a result"));
if (crop.busy || editor.stroke) throw Error(T("Finish editing before adopting a result"));
@@ -39,7 +39,7 @@ export function installResultAdoption(node, crop) {
const body = new FormData();
body.append("image", file, `adopted-${crypto.randomUUID()}.png`);
body.append("type", "input"); body.append("subfolder", "adachi-inpaint-layers");
const uploaded = await uploadImageWithProgress(api,body,null,{signal:controller.signal});
const uploaded = await uploadImageWithProgress(api,body,onProgress,{signal:controller.signal});
if (typeof uploaded.name !== "string" || !uploaded.name) throw Error(T("Upload failed"));
if (editor.destroyed || crop.mode !== "crop" || crop.region.job !== region.job) {
throw Error(T("The crop changed while adopting the result"));
+10
View File
@@ -1,3 +1,13 @@
/** Keep the DOM widget host stable while its contents are mounted elsewhere.
* Vue may reattach widget.element when switching renderers or viewport sizes. */
export function createWidgetHost(content) {
const host = document.createElement("div");
host.className = "adachi-widget-host";
Object.assign(host.style, {width:"100%", height:"100%", minWidth:"0", minHeight:"0"});
host.append(content);
return host;
}
/** Fill the node in graph coordinates, independently of cached DOM measurements. */
export function bindWidgetToNodeWidth(node, widget, element) {
// Classic ComfyUI uses widget.width before node.width for its DOM host.
+2
View File
@@ -154,3 +154,5 @@ body.adachi-workspace-open > :not(.adachi-workspace):not(script):not(style):not(
.adachi-workspace .aw-connections button {padding:4px 8px;margin-top:8px;}
.adachi-workspace .aw-connections p {color:var(--aw-error);}
.adachi-workspace .aw-bindings details small {display:block;overflow-wrap:anywhere;}
.adachi-workspace .aw-quick-params {height:auto;flex:none;border:0;border-radius:0;overflow:visible;gap:8px;padding:12px 0;background:transparent;container-type:normal;}
+33 -5
View File
@@ -133,6 +133,11 @@ export class WorkspaceBindings {
render() {
if(!this.root || this.destroyed)return;
const doc=this.root.ownerDocument;
const active=doc.activeElement;
const focus=active?.tagName==='TEXTAREA' && this.root.contains(active)?{
id:active.dataset.executionId,field:active.dataset.widget,
start:active.selectionStart,end:active.selectionEnd,direction:active.selectionDirection,scroll:active.scrollTop,
}:null;
const el=(tag,text)=>{const e=doc.createElement(tag);if(text)e.textContent=text;return e;};
const row=(label,control)=>{const l=el('label',label);l.append(control);return l;};
const select=(values,current,change)=>{const s=el('select');for(const [value,label] of values){const o=el('option',label);o.value=value;s.append(o);}s.value=current;s.onchange=()=>change(s.value);return s;};
@@ -175,9 +180,23 @@ export class WorkspaceBindings {
this.root.append(details);
for(const [kind,label] of [['positive',T("Positive prompt")],['negative',T("Negative prompt")]]) {
const entries=this.flow?.encoders[kind]||[];
if(!entries.length){const info=el('p',label+": "+(this.flow?T("Controlled by the original workflow"):T("Reading connections…")));info.title=T("Encoders are identified from the execution graph; unrecognized conditioning stays controlled by the original workflow");this.root.append(info);continue;}
const container=entries.length>1?el('details'):this.root;
if(entries.length>1){container.append(el('summary',T("{label} · {count} encoders",{label,count:entries.length})));this.root.append(container);}
const container=el('details');container.className='aw-prompt-section';container.dataset.promptKind=kind;
container.append(el('summary',entries.length>1?T("{label} · {count} encoders",{label,count:entries.length}):label));
const savedOpen=this.sampler.properties?.adachi_workspace_ui?.promptOpen?.[kind];
let lastOpen=typeof savedOpen==='boolean'?savedOpen:entries.length<=1;
container.open=lastOpen;
container.ontoggle=()=>{
// Native <details> emits asynchronous toggle events even when
// open is initialized programmatically or an old panel is removed.
if(!container.isConnected || container.open===lastOpen)return;
lastOpen=container.open;this.sampler.properties??={};
let ui=this.sampler.properties.adachi_workspace_ui;
if(!ui || typeof ui!=='object' || Array.isArray(ui))ui=this.sampler.properties.adachi_workspace_ui={};
if(!ui.promptOpen || typeof ui.promptOpen!=='object' || Array.isArray(ui.promptOpen))ui.promptOpen={};
ui.promptOpen[kind]=lastOpen;this.markChanged();
};
this.root.append(container);
if(!entries.length){const info=el('p',this.flow?T("Controlled by the original workflow"):T("Reading connections…"));info.title=T("Encoders are identified from the execution graph; unrecognized conditioning stays controlled by the original workflow");container.append(info);continue;}
for(const entry of entries)for(const name of entry.fields){
const w=widget(entry.node,name),linked=Array.isArray(entry.api.inputs?.[name]);
const saved=this.settings.textOverrides[entry.id]||{},legacy=entries.length===1&&name==='text'?this.settings[kind]:undefined;
@@ -187,12 +206,21 @@ export class WorkspaceBindings {
text.dataset.executionId=entry.id;text.dataset.widget=name;
text.oninput=()=>{this.settings.textOverrides[entry.id]??={};this.settings.textOverrides[entry.id][name]=text.value;this.changed();};
const title=entries.length>1||entry.fields.length>1?`${label} · ${entry.node?.title||entry.api.class_type} · ${name}`:label;
text.setAttribute('aria-label',title);const field=row(title,text);
text.setAttribute('aria-label',title);const field=row(entries.length===1&&entry.fields.length===1?'':title,text);
field.title=T("Actual sampler connections · {node} #{id} · {field}. {description}",{node:entry.node?.title||entry.api.class_type,id:entry.id,field:name,description:linked?T("Text comes from upstream; typing creates an override."):T("Changes affect only this encoder; downstream conditioning is preserved.")});
const reset=el('button','↶');reset.type='button';reset.title=T("Restore {label} from the workflow",{label:title});reset.onclick=()=>{delete this.settings.textOverrides[entry.id]?.[name];if(entries.length===1&&name==='text')delete this.settings[kind];this.render();this.changed();};field.append(reset);container.append(field);
}
}
// Catalog and route discovery can re-render the panel asynchronously.
// Restore the active prompt and caret only when that field still exists
// and is visible; switching profile or closing a fold must not steal focus.
if(focus){
const target=[...this.root.querySelectorAll('textarea')].find(el=>el.dataset.executionId===focus.id && el.dataset.widget===focus.field);
if(target && !target.closest('details:not([open])')){
target.focus({preventScroll:true});
target.setSelectionRange(focus.start,focus.end,focus.direction);target.scrollTop=focus.scroll;
}
}
}
destroy() {this.destroyed=true;this.catalogController?.abort();this.root?.remove();}
}
+8 -5
View File
@@ -1,7 +1,7 @@
import {T} from './i18n.js';
// Session-only source checkpoints for the full-screen workspace. Capturing and
// restoring never uploads a new image or serializes the rest of the workflow.
const WIDGETS = ["image", "mask_data", "source_data", "edit_mode", "crop_state", "blend_data", "layer_image"];
const WIDGETS = ["image", "mask_data", "source_data", "edit_mode", "crop_state", "blend_data", "layer_image", "paint_data"];
const copyCommands = commands => (commands || []).map(command => ({ ...command,
...(command.points ? {points: command.points.map(point => ({...point}))} : {}),
}));
@@ -27,7 +27,7 @@ async function decodeCanvas(data) {
export function captureSourceState(node) {
const editor = node.adachiEditor, crop = node.adachiCrop;
if (!editor?.ready || !editor.image || editor.destroyed) throw Error(T("Load a source image first"));
if (editor.stroke || editor.pointerId != null || crop?.busy) throw Error(T("Finish the current edit first"));
if (editor.stroke || editor.pointerId != null || crop?.busy || node.adachiColorPaint?.drawing || node.adachiColorPaint?.restoring) throw Error(T("Finish the current edit first"));
editor.flush();
const target = crop?.target || "mask", contexts = {};
for (const [name, context] of Object.entries(crop?.contexts || {})) {
@@ -56,9 +56,10 @@ function releaseContexts(contexts, active) {
export async function restoreSourceState(node, state, {decode = decodeCanvas, onRestore} = {}) {
const editor = node.adachiEditor, crop = node.adachiCrop;
if (!editor || editor.destroyed || !node.graph) throw Error(T("The source image node was removed"));
if (editor.stroke || editor.pointerId != null || crop?.busy) throw Error(T("Finish the current edit first"));
const loadVersion = editor.loadVersion, maskRevision = editor.maskRevision, cropRevision=crop?.revision;
if (editor.stroke || editor.pointerId != null || crop?.busy || node.adachiColorPaint?.drawing || node.adachiColorPaint?.restoring) throw Error(T("Finish the current edit first"));
const loadVersion = editor.loadVersion, maskRevision = editor.maskRevision, cropRevision=crop?.revision, paintRevision=node.adachiColorPaint?.revision;
const contexts = {};
let paintSnapshot;
try {
// Decode all masks before altering live state. A failed decode leaves
// the working image, mask and crop completely untouched.
@@ -69,7 +70,8 @@ export async function restoreSourceState(node, state, {decode = decodeCanvas, on
const [mask,baseMask]=decoded.map(item=>item.value);
contexts[name] = {...context, mask, baseMask, history:copyCommands(context.history), future:copyCommands(context.future)};
}
if (editor.destroyed || !node.graph || editor.loadVersion !== loadVersion || editor.maskRevision !== maskRevision || crop?.revision !== cropRevision || editor.stroke || editor.pointerId != null || crop?.busy) {
if(node.adachiColorPaint?.prepareRestore) paintSnapshot = await node.adachiColorPaint.prepareRestore(state.widgets.paint_data || "",state.image.width,state.image.height);
if (editor.destroyed || !node.graph || editor.loadVersion !== loadVersion || editor.maskRevision !== maskRevision || crop?.revision !== cropRevision || node.adachiColorPaint?.revision !== paintRevision || editor.stroke || editor.pointerId != null || crop?.busy || node.adachiColorPaint?.drawing || node.adachiColorPaint?.restoring) {
throw Error(T("The image changed during restoration; try again"));
}
} catch (error) { releaseContexts(contexts); throw error; }
@@ -100,6 +102,7 @@ export async function restoreSourceState(node, state, {decode = decodeCanvas, on
crop.hover = null; crop.revision++; crop.patchKey = null;
crop.syncPreset?.(); crop.sync?.();
}
if(node.adachiColorPaint?.commitRestore) node.adachiColorPaint.commitRestore(paintSnapshot,state.image.width,state.image.height);
releaseContexts(oldContexts, active);
editor.updateButtons?.(); editor.schedule?.();
node.graph.setDirtyCanvas?.(true, true);
+28 -9
View File
@@ -11,7 +11,7 @@ import { prepareWorkspacePrompt, validateWorkspace, submitWorkspace } from './wo
import { syncCurrentDimensions } from './parameter_bridge.js?v=0.8.2';
const el=(tag,text,cls)=>{const e=document.createElement(tag);if(text)e.textContent=text;if(cls)e.className=cls;return e;};
const btn=(text,fn)=>{const b=el('button',text);b.type='button';b.onclick=fn;return b;};
const icons={edit:'<path d="m14 3 7 7-11 11H3v-7L14 3Z"/><path d="m11 6 7 7"/>',compare:'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 2v20M3 8h9M3 13h9M3 18h9"/>',side:'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 4v16M6 12h12m-3-3 3 3-3 3"/>',theme:'<path d="M20 15.5A9 9 0 0 1 8.5 4 9 9 0 1 0 20 15.5Z"/>',check:'<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',back:'<path d="m9 5-7 7 7 7M3 12h19"/>'};
const icons={edit:'<path d="m14 3 7 7-11 11H3v-7L14 3Z"/><path d="m11 6 7 7"/>',compare:'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 2v20M3 8h9M3 13h9M3 18h9"/>',side:'<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 4v16M6 12h12m-3-3 3 3-3 3"/>',theme:'<path d="M20 15.5A9 9 0 0 1 8.5 4 9 9 0 1 0 20 15.5Z"/>',check:'<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',back:'<path d="m9 5-7 7 7 7M3 12h19"/>',fit:'<path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/>'};
const iconButton=(label,kind,fn)=>{const b=btn('',fn);b.title=label;b.setAttribute('aria-label',label);b.innerHTML=`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[kind]}</svg>`;b.className='aw-icon';return b;};
let currentWorkspace=null;
export class Workspace {
@@ -24,12 +24,12 @@ export class Workspace {
const main=el('main'),canvas=el('section','','aw-canvas'),toolbar=el('div','','aw-toolbar');
this.tabs={edit:iconButton(T("Edit mask"),'edit',()=>this.show('edit')),compare:iconButton(T("Slide to compare"),'compare',()=>this.show('compare'))};
this.sideButton=iconButton(T("Show before"),'side',()=>this.show(this.view==='before'?'after':'before'));
toolbar.append(this.tabs.edit,this.tabs.compare,this.sideButton);
this.fitCompareButton=iconButton(T("Fit comparison image"),'fit',()=>this.compare?.fitView());this.fitCompareButton.disabled=true;toolbar.append(this.tabs.edit,this.tabs.compare,this.sideButton,this.fitCompareButton);
toolbar.append(el('span','','spacer'));this.adoptButton=btn(T("Adopt"),()=>this.adopt());this.adoptButton.title=T("Use this result as the next source image and keep the mask; restore earlier images below");this.adoptButton.className='primary';toolbar.append(this.adoptButton);
this.surface=el('div','','aw-surface');this.still=el('img','','aw-result-image');this.still.alt=T("Selected source image or generated result");this.still.hidden=true;this.surface.append(this.still);
const foot=el('div','','aw-footer');this.undoButton=btn(T("↶ Undo"),()=>this.restoreHistory('undo'));this.originalButton=btn(T("Initial source"),()=>this.restoreHistory('original'));this.historySelect=el('select');this.historySelect.setAttribute('aria-label',T("Choose an adopted source image"));this.historySelect.onchange=()=>this.restoreHistory(Number(this.historySelect.value));foot.append(this.undoButton,this.originalButton,this.historySelect);
this.footer=foot;this.undoButton.title=T("Restore the previous source image, mask and crop");this.originalButton.title=T("Restore the initial source image of this session");this.historySelect.title=T("Choose an earlier adopted or uploaded source image");this.note=el('div','','aw-note');canvas.append(toolbar,this.surface,foot,this.note);
this.side=el('aside');this.generationPanel=el('div','','aw-generation-progress');this.generationPanel.hidden=true;this.generationLabel=el('span');this.generationBar=el('progress');this.generationBar.max=1;this.generationBar.setAttribute('aria-label',T("Current stage progress"));this.generationPanel.title=T("Actual progress from the current node; stages without step counts show an indeterminate bar");this.generationPanel.append(this.generationLabel,this.generationBar);this.side.append(this.generationPanel,el('h2',T("Workflow settings")));this.settings=el('div');this.params=el('div','','aw-parameters');this.runButton=btn(T("Generate"),()=>this.run());this.runButton.disabled=true;this.runButton.className='primary';this.runButton.style.width='100%';this.side.append(this.settings,this.runButton,this.params);this.divider=el('div','','aw-splitter');main.append(canvas,this.divider,this.side);this.root.append(main);
this.side=el('aside');this.generationPanel=el('div','','aw-generation-progress');this.generationPanel.hidden=true;this.generationLabel=el('span');this.generationBar=el('progress');this.generationBar.max=1;this.generationBar.setAttribute('aria-label',T("Current stage progress"));this.generationPanel.title=T("Actual progress from the current node; stages without step counts show an indeterminate bar");this.generationPanel.append(this.generationLabel,this.generationBar);this.side.append(this.generationPanel,el('h2',T("Workflow settings")));this.settings=el('div');this.params=el('div','','aw-parameters');this.quickParams=el('div','','dd-inpaint aw-quick-params');this.runButton=btn(T("Generate"),()=>this.run());this.runButton.disabled=true;this.runButton.className='primary';this.runButton.style.width='100%';this.side.append(this.runButton,this.quickParams,this.settings,this.params);this.divider=el('div','','aw-splitter');main.append(canvas,this.divider,this.side);this.root.append(main);
this.root.addEventListener('keydown',e=>{if(e.key==='Escape'&&e.target===this.root)this.close();});
this.root.addEventListener('pointerdown',e=>e.stopPropagation());document.body.append(this.root);document.body.classList.add('adachi-workspace-open');this.stopSplitter=installWorkspaceSplitter(this.root,main,this.divider);
// Render first. Discovery/catalog and actual server validation run afterward.
@@ -69,6 +69,13 @@ export class Workspace {
this.compare.options.getTargets=()=>({...originalTargets(),preferred:executionId(this.graph,this.editorNode)});this.compare.manualTarget=false;this.compare.refreshTargets();
this.disposers.push(()=>{this.compare.options.getTargets=originalTargets;this.compare.manualTarget=originalManual;this.compare.targetSelect.value=originalTarget;this.compare.refreshTargets();});
this.mount(this.editor.root,this.surface,true);this.mount(this.compare.root,this.surface,true);this.mount(this.sampler.adachiWorkbench.root,this.params);
// Move the existing controls, rather than copying widgets: linked inputs,
// source-size synchronization and sampler updates keep their native wiring.
this.sampler.adachiParameters.refresh();
for(const name of ['area','resolution','width','height','denoise']){
const field=this.sampler.adachiParameters.body.querySelector(`:scope > [data-adachi-param="${name}"]`);
if(field)this.mount(field,this.quickParams);
}
// The dock can wrap on narrow screens or with long history labels. Keep
// crop/result controls above its measured height instead of a fixed offset.
const dockObserver=new ResizeObserver(()=>{
@@ -81,6 +88,7 @@ export class Workspace {
const editorWheel=this.editor.options.onGraphWheel,compareWheel=this.compare.options.onGraphWheel;
this.editor.options.onGraphWheel=()=>{};this.compare.options.onGraphWheel=()=>{};
this.disposers.push(()=>{this.editor.options.onGraphWheel=editorWheel;this.compare.options.onGraphWheel=compareWheel;});
this.compare.enableWorkspaceZoom(true);this.disposers.push(()=>{this.compare.setDisplayMode('compare');this.compare.enableWorkspaceZoom(false);});
this.bindings=new WorkspaceBindings({graph:this.graph,sampler:this.sampler,canvas:app.canvas,onChange:()=>this.scheduleCheck(),markChanged:()=>{app.canvas?.emitBeforeChange?.();app.canvas?.emitAfterChange?.();}});
this.settings.append(this.connectionPicker(),this.bindings.createPanel());
this.prepare(true).catch(e=>this.setStatus(e.message,'error'));
@@ -92,7 +100,7 @@ export class Workspace {
this.compare.loadSelected=async(...args)=>{const result=await oldLoad.apply(this.compare,args);if(!this.closed){this.show(this.view||'edit');this.updateButtons();}return result;};
this.disposers.push(()=>{this.compare.loadSelected=oldLoad;});
const oldUpload=this.editor.upload;
this.editor.upload=async(...args)=>{await oldUpload(...args);if(this.editor.ready&&!this.closed){await this.history.checkpoint(T("Uploaded source"));this.syncHistory();this.scheduleCheck();}};
this.editor.upload=async(...args)=>{const previous=this.editor.image;await oldUpload(...args);if(this.editor.ready&&this.editor.image!==previous&&!this.closed){await this.history.checkpoint(T("Uploaded source"));this.syncHistory();this.scheduleCheck();}};
this.disposers.push(()=>{this.editor.upload=oldUpload;});
const onExecuted=this.compareNode.onExecuted;
this.compareNode.onExecuted=(output)=>{const ret=onExecuted?.call(this.compareNode,output);if(output?.adachi_results?.length){this.showGeneratedResult();this.note.textContent='';this.updateButtons();}return ret;};
@@ -105,7 +113,7 @@ export class Workspace {
const recovered=e=>{if(e.detail.node!==this.compareNode || e.detail.promptId!==this.promptId)return;if(e.detail.entry.outputs?.[executionId(this.graph,this.compareNode)]?.adachi_results?.length)this.showGeneratedResult();this.taskProgress.finish(e.detail.entry.status?.status_str==='success');this.running=false;this.sampler.adachiWorkspaceTask=null;this.runButton.disabled=false;this.runButton.textContent=T("Generate");this.setStatus(e.detail.entry.status?.status_str==='success'?T("Generation complete"):T("Generation failed; check the ComfyUI task history"),e.detail.entry.status?.status_str==='success'?'ok':'error');this.updateButtons();};
document.addEventListener('adachi-result-recovered',recovered);this.disposers.push(()=>document.removeEventListener('adachi-result-recovered',recovered));
for(const name of ['execution_success','execution_error','execution_interrupted'])event(name,e=>{if(e.detail.prompt_id===this.promptId){this.running=false;this.sampler.adachiWorkspaceTask=null;this.runButton.disabled=false;this.runButton.textContent=T("Generate");this.setStatus(name==='execution_success'?T("Generation complete"):e.detail.exception_message||T("Generation stopped"),name==='execution_success'?'ok':'error');}});
this.timer=setInterval(()=>{if((app.rootGraph||app.graph)!==this.graph||this.editor.destroyed)this.close();else{this.updateButtons();if(this.pendingResultView)this.showGeneratedResult();}},500);
this.timer=setInterval(()=>{if((app.rootGraph||app.graph)!==this.graph||this.editor.destroyed||!this.sampler?.graph||!this.compareNode?.graph)this.close();else{this.updateButtons();if(this.pendingResultView)this.showGeneratedResult();}},500);
// The comparison's exact-task recovery also serves the workspace. A second
// history poller doubled weak-network traffic and could replay old results.
this.promptId=this.sampler.adachiWorkspaceTask;this.running=!!this.promptId;if(this.running){if(this.taskProgress.state==='idle')this.taskProgress.begin({},this.promptId);this.compareNode.adachiTrackPrompt?.(this.promptId);this.runButton.disabled=true;this.runButton.textContent=T("Generating…");}this.show('edit');this.syncHistory();this.scheduleCheck();
@@ -126,8 +134,19 @@ export class Workspace {
});apply.title=T("Select nodes from this workflow; this inpaint node remembers your choices");panel.append(apply);return panel;
}
syncHistory(){if(!this.history)return;this.historySelect.replaceChildren();this.history.entries.forEach((item,i)=>{const o=el('option',`${i+1} · ${item.label}`);o.value=i;this.historySelect.append(o);});this.historySelect.value=String(this.history.index);this.undoButton.disabled=!this.history.canUndo;this.originalButton.disabled=this.history.index===0;}
updateButtons(){const busy=this.running||this.preparing||this.submitting||this.sampler?.adachiWorkspaceSubmitting;this.runButton.disabled=!!(this.running||this.submitting||this.sampler?.adachiWorkspaceSubmitting);this.adoptButton.disabled=!this.compare?.ready||!!this.compare?.busy||this.compare?.pendingOutputs>0||busy;this.tabs.compare.disabled=!this.compare?.ready;this.sideButton.disabled=!this.compare?.ready;if(!this.preparing)this.bindings?.refreshProfiles();if(this.editor)this.editor.root.inert=(!this.connectionsConfirmed)||!!this.preparing&&!this.discovering;this.settings.inert=!!this.preparing;this.params.inert=!!this.preparing;this.historySelect.disabled=this.preparing||!!this.history?.busy;this.undoButton.disabled=this.preparing||!this.history?.canUndo;this.originalButton.disabled=this.preparing||this.history?.index<=0;if(this.compare?.adoptButton)this.compare.adoptButton.disabled=this.adoptButton.disabled;}
show(which){this.view=which;if(this.editor)this.editor.root.hidden=which!=='edit';if(this.compare)this.compare.root.hidden=which!=='compare';this.still.hidden=!['before','after'].includes(which);if(!this.still.hidden){const image=which==='before'?this.compare?.before:this.compare?.after;if(image)this.still.src=image.src;}for(const [id,b]of Object.entries(this.tabs)){b.classList.toggle('active',id===which);b.setAttribute('aria-pressed',String(id===which));}this.sideButton.classList.toggle('active',['before','after'].includes(which));this.sideButton.setAttribute('aria-pressed',String(which==='before'));this.sideButton.title=which==='before'?T("Show after"):T("Show before");this.sideButton.setAttribute('aria-label',this.sideButton.title);this.editor?.schedule();this.compare?.schedule();}
updateButtons(){const busy=this.running||this.preparing||this.submitting||this.sampler?.adachiWorkspaceSubmitting;this.runButton.disabled=!!(this.running||this.submitting||this.sampler?.adachiWorkspaceSubmitting);this.adoptButton.disabled=!this.compare?.ready||!!this.compare?.busy||this.compare?.pendingOutputs>0||busy;this.tabs.compare.disabled=!this.compare?.ready;this.sideButton.disabled=!this.compare?.ready;this.fitCompareButton.disabled=!this.compare?.ready||!['compare','before','after'].includes(this.view);if(!this.preparing)this.bindings?.refreshProfiles();if(this.editor)this.editor.root.inert=(!this.connectionsConfirmed)||!!this.preparing&&!this.discovering;this.settings.inert=!!this.preparing;this.params.inert=!!this.preparing;this.quickParams.inert=!!this.preparing;this.historySelect.disabled=this.preparing||!!this.history?.busy;this.undoButton.disabled=this.preparing||!this.history?.canUndo;this.originalButton.disabled=this.preparing||this.history?.index<=0;if(this.compare?.adoptButton)this.compare.adoptButton.disabled=this.adoptButton.disabled;}
show(which){
this.view=which;
if(this.editor)this.editor.root.hidden=which!=='edit';
const comparing=['compare','before','after'].includes(which);
if(this.compare){this.compare.root.hidden=!comparing;if(comparing)this.compare.setDisplayMode(which);}
this.still.hidden=true;
for(const [id,b]of Object.entries(this.tabs)){b.classList.toggle('active',id===which);b.setAttribute('aria-pressed',String(id===which));}
this.sideButton.classList.toggle('active',['before','after'].includes(which));this.sideButton.setAttribute('aria-pressed',String(which==='before'));
this.sideButton.title=which==='before'?T("Show after"):T("Show before");this.sideButton.setAttribute('aria-label',this.sideButton.title);
this.fitCompareButton.disabled=!this.compare?.ready||!comparing;
this.editor?.schedule();this.compare?.schedule();
}
async adopt(){if(!this.compare?.ready||this.running)return;await this.compare.adopt();this.syncHistory();this.updateButtons();if(!this.compare.message.classList.contains('dd-error')){this.show(this.editorNode.adachiCrop?.mode==='crop'?'edit':'compare');this.note.textContent='';this.scheduleCheck();}}
async restoreHistory(which){try{if(this.preparing||this.history?.busy)return;if(which==='undo')await this.history.undo();else if(which==='original')await this.history.original();else await this.history.restore(which);this.syncHistory();}catch(e){this.setStatus(e.message,'error');}}
scheduleCheck(){++this.checkVersion;this.checkController?.abort();clearTimeout(this.checkTimer);if(!this.closed)this.checkTimer=setTimeout(()=>this.check(),200);}
@@ -197,9 +216,9 @@ export class Workspace {
}
}
close(){if(this.closed)return;this.closed=true;this.sessionController.abort();this.checkController?.abort();++this.checkVersion;clearInterval(this.timer);clearInterval(this.pollTimer);clearTimeout(this.checkTimer);this.stopSplitter?.();for(const dispose of this.disposers.reverse())dispose();if(this.history){this.history.onChange=null;this.history.onRestore=null;}if(this.taskProgress)this.taskProgress.onChange=()=>{};this.bindings?.destroy();for(const {element,marker,style}of this.mounts.reverse()){element.classList.remove('aw-embedded');element.hidden=false;element.inert=false;if(marker.parentNode)marker.replaceWith(element);if(style===null)element.removeAttribute('style');else element.setAttribute('style',style);}this.root.remove();document.body.classList.remove('adachi-workspace-open');this.editor?.schedule();this.compare?.schedule();this.compare?.updateButton();currentWorkspace=null;}
close(){if(this.closed)return;this.closed=true;this.sessionController.abort();this.checkController?.abort();++this.checkVersion;clearInterval(this.timer);clearInterval(this.pollTimer);clearTimeout(this.checkTimer);this.stopSplitter?.();for(const dispose of this.disposers.reverse())dispose();if(this.history){this.history.onChange=null;this.history.onRestore=null;}if(this.taskProgress)this.taskProgress.onChange=()=>{};this.bindings?.destroy();for(const {element,marker,style}of this.mounts.reverse()){element.classList.remove('aw-embedded');const removed=(element===this.editor?.root&&(this.editor.destroyed||!this.editorNode?.graph))||(element===this.compare?.root&&!this.compareNode?.graph)||(element===this.sampler?.adachiWorkbench?.root&&!this.sampler?.graph);if(removed){marker.remove();element.remove();continue;}element.hidden=false;element.inert=false;if(marker.parentNode)marker.replaceWith(element);if(style===null)element.removeAttribute('style');else element.setAttribute('style',style);}this.root.remove();document.body.classList.remove('adachi-workspace-open');this.editor?.schedule();this.compare?.schedule();this.compare?.updateButton();currentWorkspace=null;}
}
app.registerExtension({name:'Adachi.InpaintWorkspace',setup(){
const css=document.createElement('link');css.rel='stylesheet';css.href=new URL('./workspace.css?v=2.0.0',import.meta.url).href;document.head.append(css);
const css=document.createElement('link');css.rel='stylesheet';css.href=new URL('./workspace.css?v=toolbar-layout-20261112',import.meta.url).href;document.head.append(css);
document.addEventListener('adachi-open-workspace',event=>{if(!currentWorkspace)currentWorkspace=new Workspace(event.detail?.sampler||null);});
}});