diff --git a/adetailer/CHANGELOG.md b/adetailer/CHANGELOG.md
new file mode 100644
index 0000000000000000000000000000000000000000..af8a888d28020932ac08da4ecdaa672891581c43
--- /dev/null
+++ b/adetailer/CHANGELOG.md
@@ -0,0 +1,235 @@
+# Changelog
+
+## 2023-07-07
+
+- v23.7.4
+- batch count > 1일때 프롬프트의 인덱스 문제 수정
+
+- v23.7.5
+- i2i의 `cached_uc`와 `cached_c`가 p의 `cached_uc`와 `cached_c`가 다른 인스턴스가 되도록 수정
+
+## 2023-07-05
+
+- v23.7.3
+- 버그 수정
+ - `object()`가 json 직렬화 안되는 문제
+ - `process`를 호출함에 따라 배치 카운트가 2이상일 때, all_prompts가 고정되는 문제
+ - `ad-before`와 `ad-preview` 이미지 파일명이 실제 파일명과 다른 문제
+ - pydantic 2.0 호환성 문제
+
+## 2023-07-04
+
+- v23.7.2
+- `mediapipe_face_mesh_eyes_only` 모델 추가: `mediapipe_face_mesh`로 감지한 뒤 눈만 사용함.
+- 매 배치 시작 전에 `scripts.postprocess`를, 후에 `scripts.process`를 호출함.
+ - 컨트롤넷을 사용하면 소요 시간이 조금 늘어나지만 몇몇 문제 해결에 도움이 됨.
+- `lora_block_weight`를 스크립트 화이트리스트에 추가함.
+ - 한번이라도 ADetailer를 사용한 사람은 수동으로 추가해야함.
+
+## 2023-07-03
+
+- v23.7.1
+- `process_images`를 진행한 뒤 `StableDiffusionProcessing` 오브젝트의 close를 호출함
+- api 호출로 사용했는지 확인하는 속성 추가
+- `NansException`이 발생했을 때 중지하지 않고 남은 과정 계속 진행함
+
+## 2023-07-02
+
+- v23.7.0
+- `NansException`이 발생하면 로그에 표시하고 원본 이미지를 반환하게 설정
+- `rich`를 사용한 에러 트레이싱
+ - install.py에 `rich` 추가
+- 생성 중에 컴포넌트의 값을 변경하면 args의 값도 함께 변경되는 문제 수정 (issue #180)
+- 터미널 로그로 ad_prompt와 ad_negative_prompt에 적용된 실제 프롬프트 확인할 수 있음 (입력과 다를 경우에만)
+
+## 2023-06-28
+
+- v23.6.4
+- 최대 모델 수 5 -> 10개
+- ad_prompt와 ad_negative_prompt에 빈칸으로 놔두면 입력 프롬프트가 사용된다는 문구 추가
+- huggingface 모델 다운로드 실패시 로깅
+- 1st 모델이 `None`일 경우 나머지 입력을 무시하던 문제 수정
+- `--use-cpu` 에 `adetailer` 입력 시 cpu로 yolo모델을 사용함
+
+## 2023-06-20
+
+- v23.6.3
+- 컨트롤넷 inpaint 모델에 대해, 3가지 모듈을 사용할 수 있도록 함
+- Noise Multiplier 옵션 추가 (PR #149)
+- pydantic 최소 버전 1.10.8로 설정 (Issue #146)
+
+## 2023-06-05
+
+- v23.6.2
+- xyz_grid에서 ADetailer를 사용할 수 있게함.
+ - 8가지 옵션만 1st 탭에 적용되도록 함.
+
+## 2023-06-01
+
+- v23.6.1
+- `inpaint, scribble, lineart, openpose, tile` 5가지 컨트롤넷 모델 지원 (PR #107)
+- controlnet guidance start, end 인자 추가 (PR #107)
+- `modules.extensions`를 사용하여 컨트롤넷 확장을 불러오고 경로를 알아내로록 변경
+- ui에서 컨트롤넷을 별도 함수로 분리
+
+## 2023-05-30
+
+- v23.6.0
+- 스크립트의 이름을 `After Detailer`에서 `ADetailer`로 변경
+ - API 사용자는 변경 필요함
+- 몇몇 설정 변경
+ - `ad_conf` → `ad_confidence`. 0~100 사이의 int → 0.0~1.0 사이의 float
+ - `ad_inpaint_full_res` → `ad_inpaint_only_masked`
+ - `ad_inpaint_full_res_padding` → `ad_inpaint_only_masked_padding`
+- mediapipe face mesh 모델 추가
+ - mediapipe 최소 버전 `0.10.0`
+
+- rich traceback 제거함
+- huggingface 다운로드 실패할 때 에러가 나지 않게 하고 해당 모델을 제거함
+
+## 2023-05-26
+
+- v23.5.19
+- 1번째 탭에도 `None` 옵션을 추가함
+- api로 ad controlnet model에 inpaint가 아닌 다른 컨트롤넷 모델을 사용하지 못하도록 막음
+- adetailer 진행중에 total tqdm 진행바 업데이트를 멈춤
+- state.inturrupted 상태에서 adetailer 과정을 중지함
+- 컨트롤넷 process를 각 batch가 끝난 순간에만 호출하도록 변경
+
+### 2023-05-25
+
+- v23.5.18
+- 컨트롤넷 관련 수정
+ - unit의 `input_mode`를 `SIMPLE`로 모두 변경
+ - 컨트롤넷 유넷 훅과 하이잭 함수들을 adetailer를 실행할 때에만 되돌리는 기능 추가
+ - adetailer 처리가 끝난 뒤 컨트롤넷 스크립트의 process를 다시 진행함. (batch count 2 이상일때의 문제 해결)
+- 기본 활성 스크립트 목록에서 컨트롤넷을 뺌
+
+### 2023-05-22
+
+- v23.5.17
+- 컨트롤넷 확장이 있으면 컨트롤넷 스크립트를 활성화함. (컨트롤넷 관련 문제 해결)
+- 모든 컴포넌트에 elem_id 설정
+- ui에 버전을 표시함
+
+
+### 2023-05-19
+
+- v23.5.16
+- 추가한 옵션
+ - Mask min/max ratio
+ - Mask merge mode
+ - Restore faces after ADetailer
+- 옵션들을 Accordion으로 묶음
+
+### 2023-05-18
+
+- v23.5.15
+- 필요한 것만 임포트하도록 변경 (vae 로딩 오류 없어짐. 로딩 속도 빨라짐)
+
+### 2023-05-17
+
+- v23.5.14
+- `[SKIP]`으로 ad prompt 일부를 건너뛰는 기능 추가
+- bbox 정렬 옵션 추가
+- sd_webui 타입힌트를 만들어냄
+- enable checker와 관련된 api 오류 수정?
+
+### 2023-05-15
+
+- v23.5.13
+- `[SEP]`으로 ad prompt를 분리하여 적용하는 기능 추가
+- enable checker를 다시 pydantic으로 변경함
+- ui 관련 함수를 adetailer.ui 폴더로 분리함
+- controlnet을 사용할 때 모든 controlnet unit 비활성화
+- adetailer 폴더가 없으면 만들게 함
+
+### 2023-05-13
+
+- v23.5.12
+- `ad_enable`을 제외한 입력이 dict타입으로 들어오도록 변경
+ - web api로 사용할 때에 특히 사용하기 쉬움
+ - web api breaking change
+- `mask_preprocess` 인자를 넣지 않았던 오류 수정 (PR #47)
+- huggingface에서 모델을 다운로드하지 않는 옵션 추가 `--ad-no-huggingface`
+
+### 2023-05-12
+
+- v23.5.11
+- `ultralytics` 알람 제거
+- 필요없는 exif 인자 더 제거함
+- `use separate steps` 옵션 추가
+- ui 배치를 조정함
+
+### 2023-05-09
+
+- v23.5.10
+- 선택한 스크립트만 ADetailer에 적용하는 옵션 추가, 기본값 `True`. 설정 탭에서 지정가능.
+ - 기본값: `dynamic_prompting,dynamic_thresholding,wildcards,wildcard_recursive`
+- `person_yolov8s-seg.pt` 모델 추가
+- `ultralytics`의 최소 버전을 `8.0.97`로 설정 (C:\\ 문제 해결된 버전)
+
+### 2023-05-08
+
+- v23.5.9
+- 2가지 이상의 모델을 사용할 수 있음. 기본값: 2, 최대: 5
+- segment 모델을 사용할 수 있게 함. `person_yolov8n-seg.pt` 추가
+
+### 2023-05-07
+
+- v23.5.8
+- 프롬프트와 네거티브 프롬프트에 방향키 지원 (PR #24)
+- `mask_preprocess`를 추가함. 이전 버전과 시드값이 달라질 가능성 있음!
+- 이미지 처리가 일어났을 때에만 before이미지를 저장함
+- 설정창의 레이블을 ADetailer 대신 더 적절하게 수정함
+
+### 2023-05-06
+
+- v23.5.7
+- `ad_use_cfg_scale` 옵션 추가. cfg 스케일을 따로 사용할지 말지 결정함.
+- `ad_enable` 기본값을 `True`에서 `False`로 변경
+- `ad_model`의 기본값을 `None`에서 첫번째 모델로 변경
+- 최소 2개의 입력(ad_enable, ad_model)만 들어오면 작동하게 변경.
+
+- v23.5.7.post0
+- `init_controlnet_ext`을 controlnet_exists == True일때에만 실행
+- webui를 C드라이브 바로 밑에 설치한 사람들에게 `ultralytics` 경고 표시
+
+### 2023-05-05 (어린이날)
+
+- v23.5.5
+- `Save images before ADetailer` 옵션 추가
+- 입력으로 들어온 인자와 ALL_ARGS의 길이가 다르면 에러메세지
+- README.md에 설치방법 추가
+
+- v23.5.6
+- get_args에서 IndexError가 발생하면 자세한 에러메세지를 볼 수 있음
+- AdetailerArgs에 extra_params 내장
+- scripts_args를 딥카피함
+- postprocess_image를 약간 분리함
+
+- v23.5.6.post0
+- `init_controlnet_ext`에서 에러메세지를 자세히 볼 수 있음
+
+### 2023-05-04
+
+- v23.5.4
+- use pydantic for arguments validation
+- revert: ad_model to `None` as default
+- revert: `__future__` imports
+- lazily import yolo and mediapipe
+
+### 2023-05-03
+
+- v23.5.3.post0
+- remove `__future__` imports
+- change to copy scripts and scripts args
+
+- v23.5.3.post1
+- change default ad_model from `None`
+
+### 2023-05-02
+
+- v23.5.3
+- Remove `None` from model list and add `Enable ADetailer` checkbox.
+- install.py `skip_install` fix.
diff --git a/adetailer/LICENSE.md b/adetailer/LICENSE.md
new file mode 100644
index 0000000000000000000000000000000000000000..15bc112be2418653138c879e8f15c7b001229324
--- /dev/null
+++ b/adetailer/LICENSE.md
@@ -0,0 +1,662 @@
+
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server 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,
+our General Public Licenses are 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.
+
+ 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.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ 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 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 work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero 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 Affero 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 Affero 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 Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero 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 Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ 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 AGPL, see
+.
diff --git a/adetailer/README.md b/adetailer/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..adeced8b6edf25d75bef3d1e3028d94ba7e4f73e
--- /dev/null
+++ b/adetailer/README.md
@@ -0,0 +1,105 @@
+# !After Detailer
+
+!After Detailer is a extension for stable diffusion webui, similar to Detection Detailer, except it uses ultralytics instead of the mmdet.
+
+## Install
+
+(from Mikubill/sd-webui-controlnet)
+
+1. Open "Extensions" tab.
+2. Open "Install from URL" tab in the tab.
+3. Enter `https://github.com/Bing-su/adetailer.git` to "URL for extension's git repository".
+4. Press "Install" button.
+5. Wait 5 seconds, and you will see the message "Installed into stable-diffusion-webui\extensions\adetailer. Use Installed tab to restart".
+6. Go to "Installed" tab, click "Check for updates", and then click "Apply and restart UI". (The next time you can also use this method to update extensions.)
+7. Completely restart A1111 webui including your terminal. (If you do not know what is a "terminal", you can reboot your computer: turn your computer off and turn it on again.)
+
+You can now install it directly from the Extensions tab.
+
+
+
+You **DON'T** need to download any model from huggingface.
+
+## Options
+
+| Model, Prompts | | |
+| --------------------------------- | ------------------------------------- | ------------------------------------------------- |
+| ADetailer model | Determine what to detect. | `None` = disable |
+| ADetailer prompt, negative prompt | Prompts and negative prompts to apply | If left blank, it will use the same as the input. |
+
+| Detection | | |
+| ------------------------------------ | -------------------------------------------------------------------------------------------- | --- |
+| Detection model confidence threshold | Only objects with a detection model confidence above this threshold are used for inpainting. | |
+| Mask min/max ratio | Only use masks whose area is between those ratios for the area of the entire image. | |
+
+If you want to exclude objects in the background, try setting the min ratio to around `0.01`.
+
+| Mask Preprocessing | | |
+| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
+| Mask x, y offset | Moves the mask horizontally and vertically by | |
+| Mask erosion (-) / dilation (+) | Enlarge or reduce the detected mask. | [opencv example](https://docs.opencv.org/4.7.0/db/df6/tutorial_erosion_dilatation.html) |
+| Mask merge mode | `None`: Inpaint each mask `Merge`: Merge all masks and inpaint `Merge and Invert`: Merge all masks and Invert, then inpaint | |
+
+Applied in this order: x, y offset → erosion/dilation → merge/invert.
+
+#### Inpainting
+
+
+
+Each option corresponds to a corresponding option on the inpaint tab.
+
+## ControlNet Inpainting
+
+You can use the ControlNet extension if you have ControlNet installed and ControlNet models.
+
+Support `inpaint, scribble, lineart, openpose, tile` controlnet models. Once you choose a model, the preprocessor is set automatically.
+
+## Model
+
+| Model | Target | mAP 50 | mAP 50-95 |
+| --------------------- | --------------------- | ----------------------------- | ----------------------------- |
+| face_yolov8n.pt | 2D / realistic face | 0.660 | 0.366 |
+| face_yolov8s.pt | 2D / realistic face | 0.713 | 0.404 |
+| hand_yolov8n.pt | 2D / realistic hand | 0.767 | 0.505 |
+| person_yolov8n-seg.pt | 2D / realistic person | 0.782 (bbox) 0.761 (mask) | 0.555 (bbox) 0.460 (mask) |
+| person_yolov8s-seg.pt | 2D / realistic person | 0.824 (bbox) 0.809 (mask) | 0.605 (bbox) 0.508 (mask) |
+| mediapipe_face_full | realistic face | - | - |
+| mediapipe_face_short | realistic face | - | - |
+| mediapipe_face_mesh | realistic face | - | - |
+
+The yolo models can be found on huggingface [Bingsu/adetailer](https://huggingface.co/Bingsu/adetailer).
+
+### User Model
+
+Put your [ultralytics](https://github.com/ultralytics/ultralytics) model in `webui/models/adetailer`. The model name should end with `.pt` or `.pth`.
+
+It must be a bbox detection or segment model and use all label.
+
+### Dataset
+
+Datasets used for training the yolo models are:
+
+#### Face
+
+- [Anime Face CreateML](https://universe.roboflow.com/my-workspace-mph8o/anime-face-createml)
+- [xml2txt](https://universe.roboflow.com/0oooooo0/xml2txt-njqx1)
+- [AN](https://universe.roboflow.com/sed-b8vkf/an-lfg5i)
+- [wider face](http://shuoyang1213.me/WIDERFACE/index.html)
+
+#### Hand
+
+- [AnHDet](https://universe.roboflow.com/1-yshhi/anhdet)
+- [hand-detection-fuao9](https://universe.roboflow.com/catwithawand/hand-detection-fuao9)
+
+#### Person
+
+- [coco2017](https://cocodataset.org/#home) (only person)
+- [AniSeg](https://github.com/jerryli27/AniSeg)
+- [skytnt/anime-segmentation](https://huggingface.co/datasets/skytnt/anime-segmentation)
+
+## Example
+
+
+
+
+[](https://ko-fi.com/F1F1L7V2N)
diff --git a/adetailer/adetailer/__init__.py b/adetailer/adetailer/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..dae6181e4042e320e7854837ed8e96b1da583c83
--- /dev/null
+++ b/adetailer/adetailer/__init__.py
@@ -0,0 +1,20 @@
+from .__version__ import __version__
+from .args import AD_ENABLE, ALL_ARGS, ADetailerArgs, EnableChecker
+from .common import PredictOutput, get_models
+from .mediapipe import mediapipe_predict
+from .ultralytics import ultralytics_predict
+
+AFTER_DETAILER = "ADetailer"
+
+__all__ = [
+ "__version__",
+ "AD_ENABLE",
+ "ADetailerArgs",
+ "AFTER_DETAILER",
+ "ALL_ARGS",
+ "EnableChecker",
+ "PredictOutput",
+ "get_models",
+ "mediapipe_predict",
+ "ultralytics_predict",
+]
diff --git a/adetailer/adetailer/__version__.py b/adetailer/adetailer/__version__.py
new file mode 100644
index 0000000000000000000000000000000000000000..8a190abc29f7440898bc2cd46c63aba14b8af51c
--- /dev/null
+++ b/adetailer/adetailer/__version__.py
@@ -0,0 +1 @@
+__version__ = "23.7.5"
diff --git a/adetailer/adetailer/args.py b/adetailer/adetailer/args.py
new file mode 100644
index 0000000000000000000000000000000000000000..e7d4178af0e9272cb6e35138ab6773a083b7b871
--- /dev/null
+++ b/adetailer/adetailer/args.py
@@ -0,0 +1,214 @@
+from __future__ import annotations
+
+from collections import UserList
+from functools import cached_property, partial
+from typing import Any, Literal, NamedTuple, Optional, Union
+
+import pydantic
+from pydantic import (
+ BaseModel,
+ Extra,
+ NonNegativeFloat,
+ NonNegativeInt,
+ PositiveInt,
+ confloat,
+ constr,
+ root_validator,
+ validator,
+)
+
+cn_model_regex = r".*(inpaint|tile|scribble|lineart|openpose).*|^None$"
+
+
+class Arg(NamedTuple):
+ attr: str
+ name: str
+
+
+class ArgsList(UserList):
+ @cached_property
+ def attrs(self) -> tuple[str]:
+ return tuple(attr for attr, _ in self)
+
+ @cached_property
+ def names(self) -> tuple[str]:
+ return tuple(name for _, name in self)
+
+
+class ADetailerArgs(BaseModel, extra=Extra.forbid):
+ ad_model: str = "None"
+ ad_prompt: str = ""
+ ad_negative_prompt: str = ""
+ ad_confidence: confloat(ge=0.0, le=1.0) = 0.3
+ ad_mask_min_ratio: confloat(ge=0.0, le=1.0) = 0.0
+ ad_mask_max_ratio: confloat(ge=0.0, le=1.0) = 1.0
+ ad_dilate_erode: int = 4
+ ad_x_offset: int = 0
+ ad_y_offset: int = 0
+ ad_mask_merge_invert: Literal["None", "Merge", "Merge and Invert"] = "None"
+ ad_mask_blur: NonNegativeInt = 4
+ ad_denoising_strength: confloat(ge=0.0, le=1.0) = 0.4
+ ad_inpaint_only_masked: bool = True
+ ad_inpaint_only_masked_padding: NonNegativeInt = 32
+ ad_use_inpaint_width_height: bool = False
+ ad_inpaint_width: PositiveInt = 512
+ ad_inpaint_height: PositiveInt = 512
+ ad_use_steps: bool = False
+ ad_steps: PositiveInt = 28
+ ad_use_cfg_scale: bool = False
+ ad_cfg_scale: NonNegativeFloat = 7.0
+ ad_use_noise_multiplier: bool = False
+ ad_noise_multiplier: confloat(ge=0.5, le=1.5) = 1.0
+ ad_restore_face: bool = False
+ ad_controlnet_model: constr(regex=cn_model_regex) = "None"
+ ad_controlnet_module: Optional[constr(regex=r".*inpaint.*|^None$")] = None
+ ad_controlnet_weight: confloat(ge=0.0, le=1.0) = 1.0
+ ad_controlnet_guidance_start: confloat(ge=0.0, le=1.0) = 0.0
+ ad_controlnet_guidance_end: confloat(ge=0.0, le=1.0) = 1.0
+ is_api: bool = True
+
+ @root_validator(skip_on_failure=True)
+ def ad_controlnt_module_validator(cls, values): # noqa: N805
+ cn_model = values.get("ad_controlnet_model", "None")
+ cn_module = values.get("ad_controlnet_module", None)
+ if "inpaint" not in cn_model or cn_module == "None":
+ values["ad_controlnet_module"] = None
+ return values
+
+ @validator("is_api", pre=True)
+ def is_api_validator(cls, v: Any): # noqa: N805
+ "tuple is json serializable but cannot be made with json deserialize."
+ return type(v) is not tuple
+
+ @staticmethod
+ def ppop(
+ p: dict[str, Any],
+ key: str,
+ pops: list[str] | None = None,
+ cond: Any = None,
+ ) -> None:
+ if pops is None:
+ pops = [key]
+ if key not in p:
+ return
+ value = p[key]
+ cond = (not bool(value)) if cond is None else value == cond
+
+ if cond:
+ for k in pops:
+ p.pop(k, None)
+
+ def extra_params(self, suffix: str = "") -> dict[str, Any]:
+ if self.ad_model == "None":
+ return {}
+
+ p = {name: getattr(self, attr) for attr, name in ALL_ARGS}
+ ppop = partial(self.ppop, p)
+
+ ppop("ADetailer prompt")
+ ppop("ADetailer negative prompt")
+ ppop("ADetailer mask min ratio", cond=0.0)
+ ppop("ADetailer mask max ratio", cond=1.0)
+ ppop("ADetailer x offset", cond=0)
+ ppop("ADetailer y offset", cond=0)
+ ppop("ADetailer mask merge/invert", cond="None")
+ ppop("ADetailer inpaint only masked", ["ADetailer inpaint padding"])
+ ppop(
+ "ADetailer use inpaint width/height",
+ [
+ "ADetailer use inpaint width/height",
+ "ADetailer inpaint width",
+ "ADetailer inpaint height",
+ ],
+ )
+ ppop(
+ "ADetailer use separate steps",
+ ["ADetailer use separate steps", "ADetailer steps"],
+ )
+ ppop(
+ "ADetailer use separate CFG scale",
+ ["ADetailer use separate CFG scale", "ADetailer CFG scale"],
+ )
+ ppop(
+ "ADetailer use separate noise multiplier",
+ ["ADetailer use separate noise multiplier", "ADetailer noise multiplier"],
+ )
+
+ ppop("ADetailer restore face")
+ ppop(
+ "ADetailer ControlNet model",
+ [
+ "ADetailer ControlNet model",
+ "ADetailer ControlNet module",
+ "ADetailer ControlNet weight",
+ "ADetailer ControlNet guidance start",
+ "ADetailer ControlNet guidance end",
+ ],
+ cond="None",
+ )
+ ppop("ADetailer ControlNet module")
+ ppop("ADetailer ControlNet weight", cond=1.0)
+ ppop("ADetailer ControlNet guidance start", cond=0.0)
+ ppop("ADetailer ControlNet guidance end", cond=1.0)
+
+ if suffix:
+ p = {k + suffix: v for k, v in p.items()}
+
+ return p
+
+
+class EnableChecker(BaseModel):
+ enable: bool
+ arg_list: list
+
+ def is_enabled(self) -> bool:
+ ad_model = ALL_ARGS[0].attr
+ if not self.enable:
+ return False
+ return any(arg.get(ad_model, "None") != "None" for arg in self.arg_list)
+
+
+_all_args = [
+ ("ad_enable", "ADetailer enable"),
+ ("ad_model", "ADetailer model"),
+ ("ad_prompt", "ADetailer prompt"),
+ ("ad_negative_prompt", "ADetailer negative prompt"),
+ ("ad_confidence", "ADetailer confidence"),
+ ("ad_mask_min_ratio", "ADetailer mask min ratio"),
+ ("ad_mask_max_ratio", "ADetailer mask max ratio"),
+ ("ad_x_offset", "ADetailer x offset"),
+ ("ad_y_offset", "ADetailer y offset"),
+ ("ad_dilate_erode", "ADetailer dilate/erode"),
+ ("ad_mask_merge_invert", "ADetailer mask merge/invert"),
+ ("ad_mask_blur", "ADetailer mask blur"),
+ ("ad_denoising_strength", "ADetailer denoising strength"),
+ ("ad_inpaint_only_masked", "ADetailer inpaint only masked"),
+ ("ad_inpaint_only_masked_padding", "ADetailer inpaint padding"),
+ ("ad_use_inpaint_width_height", "ADetailer use inpaint width/height"),
+ ("ad_inpaint_width", "ADetailer inpaint width"),
+ ("ad_inpaint_height", "ADetailer inpaint height"),
+ ("ad_use_steps", "ADetailer use separate steps"),
+ ("ad_steps", "ADetailer steps"),
+ ("ad_use_cfg_scale", "ADetailer use separate CFG scale"),
+ ("ad_cfg_scale", "ADetailer CFG scale"),
+ ("ad_use_noise_multiplier", "ADetailer use separate noise multiplier"),
+ ("ad_noise_multiplier", "ADetailer noise multiplier"),
+ ("ad_restore_face", "ADetailer restore face"),
+ ("ad_controlnet_model", "ADetailer ControlNet model"),
+ ("ad_controlnet_module", "ADetailer ControlNet module"),
+ ("ad_controlnet_weight", "ADetailer ControlNet weight"),
+ ("ad_controlnet_guidance_start", "ADetailer ControlNet guidance start"),
+ ("ad_controlnet_guidance_end", "ADetailer ControlNet guidance end"),
+]
+
+AD_ENABLE = Arg(*_all_args[0])
+_args = [Arg(*args) for args in _all_args[1:]]
+ALL_ARGS = ArgsList(_args)
+
+BBOX_SORTBY = [
+ "None",
+ "Position (left to right)",
+ "Position (center to edge)",
+ "Area (large to small)",
+]
+MASK_MERGE_INVERT = ["None", "Merge", "Merge and Invert"]
diff --git a/adetailer/adetailer/common.py b/adetailer/adetailer/common.py
new file mode 100644
index 0000000000000000000000000000000000000000..a29bdd5beb2ac3e1f027398dc176e70fb77d082d
--- /dev/null
+++ b/adetailer/adetailer/common.py
@@ -0,0 +1,127 @@
+from __future__ import annotations
+
+from collections import OrderedDict
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional, Union
+
+from huggingface_hub import hf_hub_download
+from PIL import Image, ImageDraw
+from rich import print
+
+repo_id = "Bingsu/adetailer"
+
+
+@dataclass
+class PredictOutput:
+ bboxes: list[list[int | float]] = field(default_factory=list)
+ masks: list[Image.Image] = field(default_factory=list)
+ preview: Optional[Image.Image] = None
+
+
+def hf_download(file: str):
+ try:
+ path = hf_hub_download(repo_id, file)
+ except Exception:
+ msg = f"[-] ADetailer: Failed to load model {file!r} from huggingface"
+ print(msg)
+ path = "INVALID"
+ return path
+
+
+def get_models(
+ model_dir: Union[str, Path], huggingface: bool = True
+) -> OrderedDict[str, Optional[str]]:
+ model_dir = Path(model_dir)
+ if model_dir.is_dir():
+ model_paths = [
+ p
+ for p in model_dir.rglob("*")
+ if p.is_file() and p.suffix in (".pt", ".pth")
+ ]
+ else:
+ model_paths = []
+
+ models = OrderedDict()
+ if huggingface:
+ models.update(
+ {
+ "face_yolov8n.pt": hf_download("face_yolov8n.pt"),
+ "face_yolov8s.pt": hf_download("face_yolov8s.pt"),
+ "hand_yolov8n.pt": hf_download("hand_yolov8n.pt"),
+ "person_yolov8n-seg.pt": hf_download("person_yolov8n-seg.pt"),
+ "person_yolov8s-seg.pt": hf_download("person_yolov8s-seg.pt"),
+ }
+ )
+ models.update(
+ {
+ "mediapipe_face_full": None,
+ "mediapipe_face_short": None,
+ "mediapipe_face_mesh": None,
+ "mediapipe_face_mesh_eyes_only": None,
+ }
+ )
+
+ invalid_keys = [k for k, v in models.items() if v == "INVALID"]
+ for key in invalid_keys:
+ models.pop(key)
+
+ for path in model_paths:
+ if path.name in models:
+ continue
+ models[path.name] = str(path)
+
+ return models
+
+
+def create_mask_from_bbox(
+ bboxes: list[list[float]], shape: tuple[int, int]
+) -> list[Image.Image]:
+ """
+ Parameters
+ ----------
+ bboxes: list[list[float]]
+ list of [x1, y1, x2, y2]
+ bounding boxes
+ shape: tuple[int, int]
+ shape of the image (width, height)
+
+ Returns
+ -------
+ masks: list[Image.Image]
+ A list of masks
+
+ """
+ masks = []
+ for bbox in bboxes:
+ mask = Image.new("L", shape, 0)
+ mask_draw = ImageDraw.Draw(mask)
+ mask_draw.rectangle(bbox, fill=255)
+ masks.append(mask)
+ return masks
+
+
+def create_bbox_from_mask(
+ masks: list[Image.Image], shape: tuple[int, int]
+) -> list[list[int]]:
+ """
+ Parameters
+ ----------
+ masks: list[Image.Image]
+ A list of masks
+ shape: tuple[int, int]
+ shape of the image (width, height)
+
+ Returns
+ -------
+ bboxes: list[list[float]]
+ A list of bounding boxes
+
+ """
+ bboxes = []
+ for mask in masks:
+ mask = mask.resize(shape)
+ bbox = mask.getbbox()
+ if bbox is not None:
+ bboxes.append(list(bbox))
+ return bboxes
diff --git a/adetailer/adetailer/mask.py b/adetailer/adetailer/mask.py
new file mode 100644
index 0000000000000000000000000000000000000000..9209b456fac2d7fda987c0b076874e3bb2cd7ee8
--- /dev/null
+++ b/adetailer/adetailer/mask.py
@@ -0,0 +1,245 @@
+from __future__ import annotations
+
+from enum import IntEnum
+from functools import partial, reduce
+from math import dist
+
+import cv2
+import numpy as np
+from PIL import Image, ImageChops
+
+from adetailer.args import MASK_MERGE_INVERT
+from adetailer.common import PredictOutput
+
+
+class SortBy(IntEnum):
+ NONE = 0
+ LEFT_TO_RIGHT = 1
+ CENTER_TO_EDGE = 2
+ AREA = 3
+
+
+class MergeInvert(IntEnum):
+ NONE = 0
+ MERGE = 1
+ MERGE_INVERT = 2
+
+
+def _dilate(arr: np.ndarray, value: int) -> np.ndarray:
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (value, value))
+ return cv2.dilate(arr, kernel, iterations=1)
+
+
+def _erode(arr: np.ndarray, value: int) -> np.ndarray:
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (value, value))
+ return cv2.erode(arr, kernel, iterations=1)
+
+
+def dilate_erode(img: Image.Image, value: int) -> Image.Image:
+ """
+ The dilate_erode function takes an image and a value.
+ If the value is positive, it dilates the image by that amount.
+ If the value is negative, it erodes the image by that amount.
+
+ Parameters
+ ----------
+ img: PIL.Image.Image
+ the image to be processed
+ value: int
+ kernel size of dilation or erosion
+
+ Returns
+ -------
+ PIL.Image.Image
+ The image that has been dilated or eroded
+ """
+ if value == 0:
+ return img
+
+ arr = np.array(img)
+ arr = _dilate(arr, value) if value > 0 else _erode(arr, -value)
+
+ return Image.fromarray(arr)
+
+
+def offset(img: Image.Image, x: int = 0, y: int = 0) -> Image.Image:
+ """
+ The offset function takes an image and offsets it by a given x(→) and y(↑) value.
+
+ Parameters
+ ----------
+ mask: Image.Image
+ Pass the mask image to the function
+ x: int
+ →
+ y: int
+ ↑
+
+ Returns
+ -------
+ PIL.Image.Image
+ A new image that is offset by x and y
+ """
+ return ImageChops.offset(img, x, -y)
+
+
+def is_all_black(img: Image.Image) -> bool:
+ arr = np.array(img)
+ return cv2.countNonZero(arr) == 0
+
+
+def bbox_area(bbox: list[float]):
+ return (bbox[2] - bbox[0]) * (bbox[3] - bbox[1])
+
+
+def mask_preprocess(
+ masks: list[Image.Image],
+ kernel: int = 0,
+ x_offset: int = 0,
+ y_offset: int = 0,
+ merge_invert: int | MergeInvert | str = MergeInvert.NONE,
+) -> list[Image.Image]:
+ """
+ The mask_preprocess function takes a list of masks and preprocesses them.
+ It dilates and erodes the masks, and offsets them by x_offset and y_offset.
+
+ Parameters
+ ----------
+ masks: list[Image.Image]
+ A list of masks
+ kernel: int
+ kernel size of dilation or erosion
+ x_offset: int
+ →
+ y_offset: int
+ ↑
+
+ Returns
+ -------
+ list[Image.Image]
+ A list of processed masks
+ """
+ if not masks:
+ return []
+
+ if x_offset != 0 or y_offset != 0:
+ masks = [offset(m, x_offset, y_offset) for m in masks]
+
+ if kernel != 0:
+ masks = [dilate_erode(m, kernel) for m in masks]
+ masks = [m for m in masks if not is_all_black(m)]
+
+ return mask_merge_invert(masks, mode=merge_invert)
+
+
+# Bbox sorting
+def _key_left_to_right(bbox: list[float]) -> float:
+ """
+ Left to right
+
+ Parameters
+ ----------
+ bbox: list[float]
+ list of [x1, y1, x2, y2]
+ """
+ return bbox[0]
+
+
+def _key_center_to_edge(bbox: list[float], *, center: tuple[float, float]) -> float:
+ """
+ Center to edge
+
+ Parameters
+ ----------
+ bbox: list[float]
+ list of [x1, y1, x2, y2]
+ image: Image.Image
+ the image
+ """
+ bbox_center = ((bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2)
+ return dist(center, bbox_center)
+
+
+def _key_area(bbox: list[float]) -> float:
+ """
+ Large to small
+
+ Parameters
+ ----------
+ bbox: list[float]
+ list of [x1, y1, x2, y2]
+ """
+ return -bbox_area(bbox)
+
+
+def sort_bboxes(
+ pred: PredictOutput, order: int | SortBy = SortBy.NONE
+) -> PredictOutput:
+ if order == SortBy.NONE or len(pred.bboxes) <= 1:
+ return pred
+
+ if order == SortBy.LEFT_TO_RIGHT:
+ key = _key_left_to_right
+ elif order == SortBy.CENTER_TO_EDGE:
+ width, height = pred.preview.size
+ center = (width / 2, height / 2)
+ key = partial(_key_center_to_edge, center=center)
+ elif order == SortBy.AREA:
+ key = _key_area
+ else:
+ raise RuntimeError
+
+ items = len(pred.bboxes)
+ idx = sorted(range(items), key=lambda i: key(pred.bboxes[i]))
+ pred.bboxes = [pred.bboxes[i] for i in idx]
+ pred.masks = [pred.masks[i] for i in idx]
+ return pred
+
+
+# Filter by ratio
+def is_in_ratio(bbox: list[float], low: float, high: float, orig_area: int) -> bool:
+ area = bbox_area(bbox)
+ return low <= area / orig_area <= high
+
+
+def filter_by_ratio(pred: PredictOutput, low: float, high: float) -> PredictOutput:
+ if not pred.bboxes:
+ return pred
+
+ w, h = pred.preview.size
+ orig_area = w * h
+ items = len(pred.bboxes)
+ idx = [i for i in range(items) if is_in_ratio(pred.bboxes[i], low, high, orig_area)]
+ pred.bboxes = [pred.bboxes[i] for i in idx]
+ pred.masks = [pred.masks[i] for i in idx]
+ return pred
+
+
+# Merge / Invert
+def mask_merge(masks: list[Image.Image]) -> list[Image.Image]:
+ arrs = [np.array(m) for m in masks]
+ arr = reduce(cv2.bitwise_or, arrs)
+ return [Image.fromarray(arr)]
+
+
+def mask_invert(masks: list[Image.Image]) -> list[Image.Image]:
+ return [ImageChops.invert(m) for m in masks]
+
+
+def mask_merge_invert(
+ masks: list[Image.Image], mode: int | MergeInvert | str
+) -> list[Image.Image]:
+ if isinstance(mode, str):
+ mode = MASK_MERGE_INVERT.index(mode)
+
+ if mode == MergeInvert.NONE or not masks:
+ return masks
+
+ if mode == MergeInvert.MERGE:
+ return mask_merge(masks)
+
+ if mode == MergeInvert.MERGE_INVERT:
+ merged = mask_merge(masks)
+ return mask_invert(merged)
+
+ raise RuntimeError
diff --git a/adetailer/adetailer/mediapipe.py b/adetailer/adetailer/mediapipe.py
new file mode 100644
index 0000000000000000000000000000000000000000..17fb2ccad1425430a994733be33cb2f0463bcb56
--- /dev/null
+++ b/adetailer/adetailer/mediapipe.py
@@ -0,0 +1,184 @@
+from __future__ import annotations
+
+from functools import partial
+
+import numpy as np
+from PIL import Image, ImageDraw
+
+from adetailer import PredictOutput
+from adetailer.common import create_bbox_from_mask, create_mask_from_bbox
+
+
+def mediapipe_predict(
+ model_type: str, image: Image.Image, confidence: float = 0.3
+) -> PredictOutput:
+ mapping = {
+ "mediapipe_face_short": partial(mediapipe_face_detection, 0),
+ "mediapipe_face_full": partial(mediapipe_face_detection, 1),
+ "mediapipe_face_mesh": mediapipe_face_mesh,
+ "mediapipe_face_mesh_eyes_only": mediapipe_face_mesh_eyes_only,
+ }
+ if model_type in mapping:
+ func = mapping[model_type]
+ return func(image, confidence)
+ msg = f"[-] ADetailer: Invalid mediapipe model type: {model_type}, Available: {list(mapping.keys())!r}"
+ raise RuntimeError(msg)
+
+
+def mediapipe_face_detection(
+ model_type: int, image: Image.Image, confidence: float = 0.3
+) -> PredictOutput:
+ import mediapipe as mp
+
+ img_width, img_height = image.size
+
+ mp_face_detection = mp.solutions.face_detection
+ draw_util = mp.solutions.drawing_utils
+
+ img_array = np.array(image)
+
+ with mp_face_detection.FaceDetection(
+ model_selection=model_type, min_detection_confidence=confidence
+ ) as face_detector:
+ pred = face_detector.process(img_array)
+
+ if pred.detections is None:
+ return PredictOutput()
+
+ preview_array = img_array.copy()
+
+ bboxes = []
+ for detection in pred.detections:
+ draw_util.draw_detection(preview_array, detection)
+
+ bbox = detection.location_data.relative_bounding_box
+ x1 = bbox.xmin * img_width
+ y1 = bbox.ymin * img_height
+ w = bbox.width * img_width
+ h = bbox.height * img_height
+ x2 = x1 + w
+ y2 = y1 + h
+
+ bboxes.append([x1, y1, x2, y2])
+
+ masks = create_mask_from_bbox(bboxes, image.size)
+ preview = Image.fromarray(preview_array)
+
+ return PredictOutput(bboxes=bboxes, masks=masks, preview=preview)
+
+
+def get_convexhull(points: np.ndarray) -> list[tuple[int, int]]:
+ """
+ Parameters
+ ----------
+ points: An ndarray of shape (n, 2) containing the 2D points.
+
+ Returns
+ -------
+ list[tuple[int, int]]: Input for the draw.polygon function
+ """
+ from scipy.spatial import ConvexHull
+
+ hull = ConvexHull(points)
+ vertices = hull.vertices
+ return list(zip(points[vertices, 0], points[vertices, 1]))
+
+
+def mediapipe_face_mesh(image: Image.Image, confidence: float = 0.3) -> PredictOutput:
+ import mediapipe as mp
+
+ mp_face_mesh = mp.solutions.face_mesh
+ draw_util = mp.solutions.drawing_utils
+ drawing_styles = mp.solutions.drawing_styles
+
+ w, h = image.size
+
+ with mp_face_mesh.FaceMesh(
+ static_image_mode=True, max_num_faces=20, min_detection_confidence=confidence
+ ) as face_mesh:
+ arr = np.array(image)
+ pred = face_mesh.process(arr)
+
+ if pred.multi_face_landmarks is None:
+ return PredictOutput()
+
+ preview = arr.copy()
+ masks = []
+
+ for landmarks in pred.multi_face_landmarks:
+ draw_util.draw_landmarks(
+ image=preview,
+ landmark_list=landmarks,
+ connections=mp_face_mesh.FACEMESH_TESSELATION,
+ landmark_drawing_spec=None,
+ connection_drawing_spec=drawing_styles.get_default_face_mesh_tesselation_style(),
+ )
+
+ points = np.array([(land.x * w, land.y * h) for land in landmarks.landmark])
+ outline = get_convexhull(points)
+
+ mask = Image.new("L", image.size, "black")
+ draw = ImageDraw.Draw(mask)
+ draw.polygon(outline, fill="white")
+ masks.append(mask)
+
+ bboxes = create_bbox_from_mask(masks, image.size)
+ preview = Image.fromarray(preview)
+ return PredictOutput(bboxes=bboxes, masks=masks, preview=preview)
+
+
+def mediapipe_face_mesh_eyes_only(
+ image: Image.Image, confidence: float = 0.3
+) -> PredictOutput:
+ import mediapipe as mp
+
+ mp_face_mesh = mp.solutions.face_mesh
+
+ left_idx = np.array(list(mp_face_mesh.FACEMESH_LEFT_EYE)).flatten()
+ right_idx = np.array(list(mp_face_mesh.FACEMESH_RIGHT_EYE)).flatten()
+
+ w, h = image.size
+
+ with mp_face_mesh.FaceMesh(
+ static_image_mode=True, max_num_faces=20, min_detection_confidence=confidence
+ ) as face_mesh:
+ arr = np.array(image)
+ pred = face_mesh.process(arr)
+
+ if pred.multi_face_landmarks is None:
+ return PredictOutput()
+
+ preview = image.copy()
+ masks = []
+
+ for landmarks in pred.multi_face_landmarks:
+ points = np.array([(land.x * w, land.y * h) for land in landmarks.landmark])
+ left_eyes = points[left_idx]
+ right_eyes = points[right_idx]
+ left_outline = get_convexhull(left_eyes)
+ right_outline = get_convexhull(right_eyes)
+
+ mask = Image.new("L", image.size, "black")
+ draw = ImageDraw.Draw(mask)
+ for outline in (left_outline, right_outline):
+ draw.polygon(outline, fill="white")
+ masks.append(mask)
+
+ bboxes = create_bbox_from_mask(masks, image.size)
+ preview = draw_preview(preview, bboxes, masks)
+ return PredictOutput(bboxes=bboxes, masks=masks, preview=preview)
+
+
+def draw_preview(
+ preview: Image.Image, bboxes: list[list[int]], masks: list[Image.Image]
+) -> Image.Image:
+ red = Image.new("RGB", preview.size, "red")
+ for mask in masks:
+ masked = Image.composite(red, preview, mask)
+ preview = Image.blend(preview, masked, 0.25)
+
+ draw = ImageDraw.Draw(preview)
+ for bbox in bboxes:
+ draw.rectangle(bbox, outline="red", width=2)
+
+ return preview
diff --git a/adetailer/adetailer/traceback.py b/adetailer/adetailer/traceback.py
new file mode 100644
index 0000000000000000000000000000000000000000..03e9afeac789fb9cc5472145e1a716dd244b0460
--- /dev/null
+++ b/adetailer/adetailer/traceback.py
@@ -0,0 +1,158 @@
+from __future__ import annotations
+
+import io
+import platform
+import sys
+from typing import Any, Callable
+
+from rich.console import Console, Group
+from rich.panel import Panel
+from rich.table import Table
+from rich.traceback import Traceback
+
+from adetailer.__version__ import __version__
+
+
+def processing(*args: Any) -> dict[str, Any]:
+ try:
+ from modules.processing import (
+ StableDiffusionProcessingImg2Img,
+ StableDiffusionProcessingTxt2Img,
+ )
+ except ImportError:
+ return {}
+
+ p = None
+ for arg in args:
+ if isinstance(
+ arg, (StableDiffusionProcessingTxt2Img, StableDiffusionProcessingImg2Img)
+ ):
+ p = arg
+ break
+
+ if p is None:
+ return {}
+
+ info = {
+ "prompt": p.prompt,
+ "negative_prompt": p.negative_prompt,
+ "n_iter": p.n_iter,
+ "batch_size": p.batch_size,
+ "width": p.width,
+ "height": p.height,
+ "sampler_name": p.sampler_name,
+ "enable_hr": getattr(p, "enable_hr", False),
+ "hr_upscaler": getattr(p, "hr_upscaler", ""),
+ }
+
+ info.update(sd_models())
+ return info
+
+
+def sd_models() -> dict[str, str]:
+ try:
+ from modules import shared
+
+ opts = shared.opts
+ except Exception:
+ return {}
+
+ return {
+ "checkpoint": getattr(opts, "sd_model_checkpoint", "------"),
+ "vae": getattr(opts, "sd_vae", "------"),
+ "unet": getattr(opts, "sd_unet", "------"),
+ }
+
+
+def ad_args(*args: Any) -> dict[str, Any]:
+ ad_args = [
+ arg
+ for arg in args
+ if isinstance(arg, dict) and arg.get("ad_model", "None") != "None"
+ ]
+ if not ad_args:
+ return {}
+
+ arg0 = ad_args[0]
+ is_api = arg0.get("is_api", True)
+ return {
+ "version": __version__,
+ "ad_model": arg0["ad_model"],
+ "ad_prompt": arg0.get("ad_prompt", ""),
+ "ad_negative_prompt": arg0.get("ad_negative_prompt", ""),
+ "ad_controlnet_model": arg0.get("ad_controlnet_model", "None"),
+ "is_api": type(is_api) is not tuple,
+ }
+
+
+def sys_info() -> dict[str, Any]:
+ try:
+ import launch
+
+ version = launch.git_tag()
+ commit = launch.commit_hash()
+ except Exception:
+ version = commit = "------"
+
+ return {
+ "Platform": platform.platform(),
+ "Python": sys.version,
+ "Version": version,
+ "Commit": commit,
+ "Commandline": sys.argv,
+ }
+
+
+def get_table(title: str, data: dict[str, Any]) -> Table:
+ table = Table(title=title, highlight=True)
+ table.add_column(" ", justify="right", style="dim")
+ table.add_column("Value")
+ for key, value in data.items():
+ if not isinstance(value, str):
+ value = repr(value)
+ table.add_row(key, value)
+
+ return table
+
+
+def force_terminal_value():
+ try:
+ from modules.shared import cmd_opts
+
+ return True if hasattr(cmd_opts, "skip_torch_cuda_test") else None
+ except Exception:
+ return None
+
+
+def rich_traceback(func: Callable) -> Callable:
+ force_terminal = force_terminal_value()
+
+ def wrapper(*args, **kwargs):
+ string = io.StringIO()
+ width = Console().width
+ width = width - 4 if width > 4 else None
+ console = Console(file=string, force_terminal=force_terminal, width=width)
+ try:
+ return func(*args, **kwargs)
+ except Exception as e:
+ tables = [
+ get_table(title, data)
+ for title, data in [
+ ("System info", sys_info()),
+ ("Inputs", processing(*args)),
+ ("ADetailer", ad_args(*args)),
+ ]
+ if data
+ ]
+ tables.append(Traceback())
+
+ console.print(Panel(Group(*tables)))
+ output = "\n" + string.getvalue()
+
+ try:
+ error = e.__class__(output)
+ except Exception:
+ error = RuntimeError(output)
+ raise error from None
+
+ return wrapper
diff --git a/adetailer/adetailer/ui.py b/adetailer/adetailer/ui.py
new file mode 100644
index 0000000000000000000000000000000000000000..876a910eaddb962ec02a0ac88cc3354e9494b6c6
--- /dev/null
+++ b/adetailer/adetailer/ui.py
@@ -0,0 +1,505 @@
+from __future__ import annotations
+
+from functools import partial
+from types import SimpleNamespace
+from typing import Any
+
+import gradio as gr
+
+from adetailer import AFTER_DETAILER, __version__
+from adetailer.args import AD_ENABLE, ALL_ARGS, MASK_MERGE_INVERT
+from controlnet_ext import controlnet_exists, get_cn_models
+
+cn_module_choices = [
+ "inpaint_global_harmonious",
+ "inpaint_only",
+ "inpaint_only+lama",
+]
+
+
+class Widgets(SimpleNamespace):
+ def tolist(self):
+ return [getattr(self, attr) for attr in ALL_ARGS.attrs]
+
+
+def gr_interactive(value: bool = True):
+ return gr.update(interactive=value)
+
+
+def ordinal(n: int) -> str:
+ d = {1: "st", 2: "nd", 3: "rd"}
+ return str(n) + ("th" if 11 <= n % 100 <= 13 else d.get(n % 10, "th"))
+
+
+def suffix(n: int, c: str = " ") -> str:
+ return "" if n == 0 else c + ordinal(n + 1)
+
+
+def on_widget_change(state: dict, value: Any, *, attr: str):
+ state[attr] = value
+ return state
+
+
+def on_generate_click(state: dict, *values: Any):
+ for attr, value in zip(ALL_ARGS.attrs, values):
+ state[attr] = value
+ state["is_api"] = ()
+ return state
+
+
+def on_cn_model_update(cn_model: str):
+ if "inpaint" in cn_model:
+ return gr.update(
+ visible=True, choices=cn_module_choices, value=cn_module_choices[0]
+ )
+ return gr.update(visible=False, choices=["None"], value="None")
+
+
+def elem_id(item_id: str, n: int, is_img2img: bool) -> str:
+ tap = "img2img" if is_img2img else "txt2img"
+ suf = suffix(n, "_")
+ return f"script_{tap}_adetailer_{item_id}{suf}"
+
+
+def adui(
+ num_models: int,
+ is_img2img: bool,
+ model_list: list[str],
+ t2i_button: gr.Button,
+ i2i_button: gr.Button,
+):
+ states = []
+ infotext_fields = []
+ eid = partial(elem_id, n=0, is_img2img=is_img2img)
+
+ with gr.Accordion(AFTER_DETAILER, open=False, elem_id=eid("ad_main_accordion")):
+ with gr.Row():
+ with gr.Column(scale=6):
+ ad_enable = gr.Checkbox(
+ label="Enable ADetailer",
+ value=False,
+ visible=True,
+ elem_id=eid("ad_enable"),
+ )
+
+ with gr.Column(scale=1, min_width=180):
+ gr.Markdown(
+ f"v{__version__}",
+ elem_id=eid("ad_version"),
+ )
+
+ infotext_fields.append((ad_enable, AD_ENABLE.name))
+
+ with gr.Group(), gr.Tabs():
+ for n in range(num_models):
+ with gr.Tab(ordinal(n + 1)):
+ state, infofields = one_ui_group(
+ n=n,
+ is_img2img=is_img2img,
+ model_list=model_list,
+ t2i_button=t2i_button,
+ i2i_button=i2i_button,
+ )
+
+ states.append(state)
+ infotext_fields.extend(infofields)
+
+ # components: [bool, dict, dict, ...]
+ components = [ad_enable, *states]
+ return components, infotext_fields
+
+
+def one_ui_group(
+ n: int,
+ is_img2img: bool,
+ model_list: list[str],
+ t2i_button: gr.Button,
+ i2i_button: gr.Button,
+):
+ w = Widgets()
+ state = gr.State({})
+ eid = partial(elem_id, n=n, is_img2img=is_img2img)
+
+ with gr.Row():
+ model_choices = [*model_list, "None"] if n == 0 else ["None", *model_list]
+
+ w.ad_model = gr.Dropdown(
+ label="ADetailer model" + suffix(n),
+ choices=model_choices,
+ value=model_choices[0],
+ visible=True,
+ type="value",
+ elem_id=eid("ad_model"),
+ )
+
+ with gr.Group():
+ with gr.Row(elem_id=eid("ad_toprow_prompt")):
+ w.ad_prompt = gr.Textbox(
+ label="ad_prompt" + suffix(n),
+ show_label=False,
+ lines=3,
+ placeholder="ADetailer prompt"
+ + suffix(n)
+ + "\nIf blank, the main prompt is used.",
+ elem_id=eid("ad_prompt"),
+ )
+
+ with gr.Row(elem_id=eid("ad_toprow_negative_prompt")):
+ w.ad_negative_prompt = gr.Textbox(
+ label="ad_negative_prompt" + suffix(n),
+ show_label=False,
+ lines=2,
+ placeholder="ADetailer negative prompt"
+ + suffix(n)
+ + "\nIf blank, the main negative prompt is used.",
+ elem_id=eid("ad_negative_prompt"),
+ )
+
+ with gr.Group():
+ with gr.Accordion(
+ "Detection", open=False, elem_id=eid("ad_detection_accordion")
+ ):
+ detection(w, n, is_img2img)
+
+ with gr.Accordion(
+ "Mask Preprocessing",
+ open=False,
+ elem_id=eid("ad_mask_preprocessing_accordion"),
+ ):
+ mask_preprocessing(w, n, is_img2img)
+
+ with gr.Accordion(
+ "Inpainting", open=False, elem_id=eid("ad_inpainting_accordion")
+ ):
+ inpainting(w, n, is_img2img)
+
+ with gr.Group():
+ controlnet(w, n, is_img2img)
+
+ all_inputs = [state, *w.tolist()]
+ target_button = i2i_button if is_img2img else t2i_button
+ target_button.click(
+ fn=on_generate_click, inputs=all_inputs, outputs=state, queue=False
+ )
+
+ infotext_fields = [(getattr(w, attr), name + suffix(n)) for attr, name in ALL_ARGS]
+
+ return state, infotext_fields
+
+
+def detection(w: Widgets, n: int, is_img2img: bool):
+ eid = partial(elem_id, n=n, is_img2img=is_img2img)
+
+ with gr.Row():
+ with gr.Column():
+ w.ad_confidence = gr.Slider(
+ label="Detection model confidence threshold" + suffix(n),
+ minimum=0.0,
+ maximum=1.0,
+ step=0.01,
+ value=0.3,
+ visible=True,
+ elem_id=eid("ad_confidence"),
+ )
+
+ with gr.Column(variant="compact"):
+ w.ad_mask_min_ratio = gr.Slider(
+ label="Mask min area ratio" + suffix(n),
+ minimum=0.0,
+ maximum=1.0,
+ step=0.001,
+ value=0.0,
+ visible=True,
+ elem_id=eid("ad_mask_min_ratio"),
+ )
+ w.ad_mask_max_ratio = gr.Slider(
+ label="Mask max area ratio" + suffix(n),
+ minimum=0.0,
+ maximum=1.0,
+ step=0.001,
+ value=1.0,
+ visible=True,
+ elem_id=eid("ad_mask_max_ratio"),
+ )
+
+
+def mask_preprocessing(w: Widgets, n: int, is_img2img: bool):
+ eid = partial(elem_id, n=n, is_img2img=is_img2img)
+
+ with gr.Group():
+ with gr.Row():
+ with gr.Column(variant="compact"):
+ w.ad_x_offset = gr.Slider(
+ label="Mask x(→) offset" + suffix(n),
+ minimum=-200,
+ maximum=200,
+ step=1,
+ value=0,
+ visible=True,
+ elem_id=eid("ad_x_offset"),
+ )
+ w.ad_y_offset = gr.Slider(
+ label="Mask y(↑) offset" + suffix(n),
+ minimum=-200,
+ maximum=200,
+ step=1,
+ value=0,
+ visible=True,
+ elem_id=eid("ad_y_offset"),
+ )
+
+ with gr.Column(variant="compact"):
+ w.ad_dilate_erode = gr.Slider(
+ label="Mask erosion (-) / dilation (+)" + suffix(n),
+ minimum=-128,
+ maximum=128,
+ step=4,
+ value=4,
+ visible=True,
+ elem_id=eid("ad_dilate_erode"),
+ )
+
+ with gr.Row():
+ w.ad_mask_merge_invert = gr.Radio(
+ label="Mask merge mode" + suffix(n),
+ choices=MASK_MERGE_INVERT,
+ value="None",
+ elem_id=eid("ad_mask_merge_invert"),
+ )
+
+
+def inpainting(w: Widgets, n: int, is_img2img: bool):
+ eid = partial(elem_id, n=n, is_img2img=is_img2img)
+
+ with gr.Group():
+ with gr.Row():
+ w.ad_mask_blur = gr.Slider(
+ label="Inpaint mask blur" + suffix(n),
+ minimum=0,
+ maximum=64,
+ step=1,
+ value=4,
+ visible=True,
+ elem_id=eid("ad_mask_blur"),
+ )
+
+ w.ad_denoising_strength = gr.Slider(
+ label="Inpaint denoising strength" + suffix(n),
+ minimum=0.0,
+ maximum=1.0,
+ step=0.01,
+ value=0.4,
+ visible=True,
+ elem_id=eid("ad_denoising_strength"),
+ )
+
+ with gr.Row():
+ with gr.Column(variant="compact"):
+ w.ad_inpaint_only_masked = gr.Checkbox(
+ label="Inpaint only masked" + suffix(n),
+ value=True,
+ visible=True,
+ elem_id=eid("ad_inpaint_only_masked"),
+ )
+ w.ad_inpaint_only_masked_padding = gr.Slider(
+ label="Inpaint only masked padding, pixels" + suffix(n),
+ minimum=0,
+ maximum=256,
+ step=4,
+ value=32,
+ visible=True,
+ elem_id=eid("ad_inpaint_only_masked_padding"),
+ )
+
+ w.ad_inpaint_only_masked.change(
+ gr_interactive,
+ inputs=w.ad_inpaint_only_masked,
+ outputs=w.ad_inpaint_only_masked_padding,
+ queue=False,
+ )
+
+ with gr.Column(variant="compact"):
+ w.ad_use_inpaint_width_height = gr.Checkbox(
+ label="Use separate width/height" + suffix(n),
+ value=False,
+ visible=True,
+ elem_id=eid("ad_use_inpaint_width_height"),
+ )
+
+ w.ad_inpaint_width = gr.Slider(
+ label="inpaint width" + suffix(n),
+ minimum=64,
+ maximum=2048,
+ step=4,
+ value=512,
+ visible=True,
+ elem_id=eid("ad_inpaint_width"),
+ )
+
+ w.ad_inpaint_height = gr.Slider(
+ label="inpaint height" + suffix(n),
+ minimum=64,
+ maximum=2048,
+ step=4,
+ value=512,
+ visible=True,
+ elem_id=eid("ad_inpaint_height"),
+ )
+
+ w.ad_use_inpaint_width_height.change(
+ lambda value: (gr_interactive(value), gr_interactive(value)),
+ inputs=w.ad_use_inpaint_width_height,
+ outputs=[w.ad_inpaint_width, w.ad_inpaint_height],
+ queue=False,
+ )
+
+ with gr.Row():
+ with gr.Column(variant="compact"):
+ w.ad_use_steps = gr.Checkbox(
+ label="Use separate steps" + suffix(n),
+ value=False,
+ visible=True,
+ elem_id=eid("ad_use_steps"),
+ )
+
+ w.ad_steps = gr.Slider(
+ label="ADetailer steps" + suffix(n),
+ minimum=1,
+ maximum=150,
+ step=1,
+ value=28,
+ visible=True,
+ elem_id=eid("ad_steps"),
+ )
+
+ w.ad_use_steps.change(
+ gr_interactive,
+ inputs=w.ad_use_steps,
+ outputs=w.ad_steps,
+ queue=False,
+ )
+
+ with gr.Column(variant="compact"):
+ w.ad_use_cfg_scale = gr.Checkbox(
+ label="Use separate CFG scale" + suffix(n),
+ value=False,
+ visible=True,
+ elem_id=eid("ad_use_cfg_scale"),
+ )
+
+ w.ad_cfg_scale = gr.Slider(
+ label="ADetailer CFG scale" + suffix(n),
+ minimum=0.0,
+ maximum=30.0,
+ step=0.5,
+ value=7.0,
+ visible=True,
+ elem_id=eid("ad_cfg_scale"),
+ )
+
+ w.ad_use_cfg_scale.change(
+ gr_interactive,
+ inputs=w.ad_use_cfg_scale,
+ outputs=w.ad_cfg_scale,
+ queue=False,
+ )
+
+ with gr.Row():
+ with gr.Column(variant="compact"):
+ w.ad_use_noise_multiplier = gr.Checkbox(
+ label="Use separate noise multiplier" + suffix(n),
+ value=False,
+ visible=True,
+ elem_id=eid("ad_use_noise_multiplier"),
+ )
+
+ w.ad_noise_multiplier = gr.Slider(
+ label="Noise multiplier for img2img" + suffix(n),
+ minimum=0.5,
+ maximum=1.5,
+ step=0.01,
+ value=1.0,
+ visible=True,
+ elem_id=eid("ad_noise_multiplier"),
+ )
+
+ w.ad_use_noise_multiplier.change(
+ gr_interactive,
+ inputs=w.ad_use_noise_multiplier,
+ outputs=w.ad_noise_multiplier,
+ queue=False,
+ )
+
+ w.ad_restore_face = gr.Checkbox(
+ label="Restore faces after ADetailer" + suffix(n),
+ value=False,
+ elem_id=eid("ad_restore_face"),
+ )
+
+
+def controlnet(w: Widgets, n: int, is_img2img: bool):
+ eid = partial(elem_id, n=n, is_img2img=is_img2img)
+ cn_models = ["None", *get_cn_models()]
+
+ with gr.Row(variant="panel"):
+ with gr.Column(variant="compact"):
+ w.ad_controlnet_model = gr.Dropdown(
+ label="ControlNet model" + suffix(n),
+ choices=cn_models,
+ value="None",
+ visible=True,
+ type="value",
+ interactive=controlnet_exists,
+ elem_id=eid("ad_controlnet_model"),
+ )
+
+ w.ad_controlnet_module = gr.Dropdown(
+ label="ControlNet module" + suffix(n),
+ choices=cn_module_choices,
+ value="inpaint_global_harmonious",
+ visible=False,
+ type="value",
+ interactive=controlnet_exists,
+ elem_id=eid("ad_controlnet_module"),
+ )
+
+ w.ad_controlnet_weight = gr.Slider(
+ label="ControlNet weight" + suffix(n),
+ minimum=0.0,
+ maximum=1.0,
+ step=0.01,
+ value=1.0,
+ visible=True,
+ interactive=controlnet_exists,
+ elem_id=eid("ad_controlnet_weight"),
+ )
+
+ w.ad_controlnet_model.change(
+ on_cn_model_update,
+ inputs=w.ad_controlnet_model,
+ outputs=w.ad_controlnet_module,
+ queue=False,
+ )
+
+ with gr.Column(variant="compact"):
+ w.ad_controlnet_guidance_start = gr.Slider(
+ label="ControlNet guidance start" + suffix(n),
+ minimum=0.0,
+ maximum=1.0,
+ step=0.01,
+ value=0.0,
+ visible=True,
+ interactive=controlnet_exists,
+ elem_id=eid("ad_controlnet_guidance_start"),
+ )
+
+ w.ad_controlnet_guidance_end = gr.Slider(
+ label="ControlNet guidance end" + suffix(n),
+ minimum=0.0,
+ maximum=1.0,
+ step=0.01,
+ value=1.0,
+ visible=True,
+ interactive=controlnet_exists,
+ elem_id=eid("ad_controlnet_guidance_end"),
+ )
diff --git a/adetailer/adetailer/ultralytics.py b/adetailer/adetailer/ultralytics.py
new file mode 100644
index 0000000000000000000000000000000000000000..b44703efa4906ec34673a246d4cdbbd1c711a589
--- /dev/null
+++ b/adetailer/adetailer/ultralytics.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import cv2
+from PIL import Image
+
+from adetailer import PredictOutput
+from adetailer.common import create_mask_from_bbox
+
+
+def ultralytics_predict(
+ model_path: str | Path,
+ image: Image.Image,
+ confidence: float = 0.3,
+ device: str = "",
+) -> PredictOutput:
+ from ultralytics import YOLO
+
+ model_path = str(model_path)
+
+ model = YOLO(model_path)
+ pred = model(image, conf=confidence, device=device)
+
+ bboxes = pred[0].boxes.xyxy.cpu().numpy()
+ if bboxes.size == 0:
+ return PredictOutput()
+ bboxes = bboxes.tolist()
+
+ if pred[0].masks is None:
+ masks = create_mask_from_bbox(bboxes, image.size)
+ else:
+ masks = mask_to_pil(pred[0].masks.data, image.size)
+ preview = pred[0].plot()
+ preview = cv2.cvtColor(preview, cv2.COLOR_BGR2RGB)
+ preview = Image.fromarray(preview)
+
+ return PredictOutput(bboxes=bboxes, masks=masks, preview=preview)
+
+
+def mask_to_pil(masks, shape: tuple[int, int]) -> list[Image.Image]:
+ """
+ Parameters
+ ----------
+ masks: torch.Tensor, dtype=torch.float32, shape=(N, H, W).
+ The device can be CUDA, but `to_pil_image` takes care of that.
+
+ shape: tuple[int, int]
+ (width, height) of the original image
+ """
+ from torchvision.transforms.functional import to_pil_image
+
+ n = masks.shape[0]
+ return [to_pil_image(masks[i], mode="L").resize(shape) for i in range(n)]
diff --git a/adetailer/controlnet_ext/__init__.py b/adetailer/controlnet_ext/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0ab666835157561426d684d798735e724a5a4dbe
--- /dev/null
+++ b/adetailer/controlnet_ext/__init__.py
@@ -0,0 +1,7 @@
+from .controlnet_ext import ControlNetExt, controlnet_exists, get_cn_models
+
+__all__ = [
+ "ControlNetExt",
+ "controlnet_exists",
+ "get_cn_models",
+]
diff --git a/adetailer/controlnet_ext/controlnet_ext.py b/adetailer/controlnet_ext/controlnet_ext.py
new file mode 100644
index 0000000000000000000000000000000000000000..f0c89187c7f670f38dfc70a7b30ef293854191af
--- /dev/null
+++ b/adetailer/controlnet_ext/controlnet_ext.py
@@ -0,0 +1,140 @@
+from __future__ import annotations
+
+import importlib
+import re
+from functools import lru_cache
+from pathlib import Path
+
+from modules import extensions, sd_models, shared
+from modules.paths import data_path, models_path, script_path
+
+ext_path = Path(data_path, "extensions")
+ext_builtin_path = Path(script_path, "extensions-builtin")
+controlnet_exists = False
+controlnet_path = None
+cn_base_path = ""
+
+for extension in extensions.active():
+ if not extension.enabled:
+ continue
+ # For cases like sd-webui-controlnet-master
+ if "sd-webui-controlnet" in extension.name:
+ controlnet_exists = True
+ controlnet_path = Path(extension.path)
+ cn_base_path = ".".join(controlnet_path.parts[-2:])
+ break
+
+cn_model_module = {
+ "inpaint": "inpaint_global_harmonious",
+ "scribble": "t2ia_sketch_pidi",
+ "lineart": "lineart_coarse",
+ "openpose": "openpose_full",
+ "tile": None,
+}
+cn_model_regex = re.compile("|".join(cn_model_module.keys()))
+
+
+class ControlNetExt:
+ def __init__(self):
+ self.cn_models = ["None"]
+ self.cn_available = False
+ self.external_cn = None
+
+ def init_controlnet(self):
+ import_path = cn_base_path + ".scripts.external_code"
+
+ self.external_cn = importlib.import_module(import_path, "external_code")
+ self.cn_available = True
+ models = self.external_cn.get_models()
+ self.cn_models.extend(m for m in models if cn_model_regex.search(m))
+
+ def update_scripts_args(
+ self,
+ p,
+ model: str,
+ module: str | None,
+ weight: float,
+ guidance_start: float,
+ guidance_end: float,
+ ):
+ if (not self.cn_available) or model == "None":
+ return
+
+ if module is None:
+ for m, v in cn_model_module.items():
+ if m in model:
+ module = v
+ break
+
+ cn_units = [
+ self.external_cn.ControlNetUnit(
+ model=model,
+ weight=weight,
+ control_mode=self.external_cn.ControlMode.BALANCED,
+ module=module,
+ guidance_start=guidance_start,
+ guidance_end=guidance_end,
+ pixel_perfect=True,
+ )
+ ]
+
+ self.external_cn.update_cn_script_in_processing(p, cn_units)
+
+
+def get_cn_model_dirs() -> list[Path]:
+ cn_model_dir = Path(models_path, "ControlNet")
+ if controlnet_path is not None:
+ cn_model_dir_old = controlnet_path.joinpath("models")
+ else:
+ cn_model_dir_old = None
+ ext_dir1 = shared.opts.data.get("control_net_models_path", "")
+ ext_dir2 = shared.opts.data.get("controlnet_dir", "")
+
+ dirs = [cn_model_dir]
+ for ext_dir in [cn_model_dir_old, ext_dir1, ext_dir2]:
+ if ext_dir:
+ dirs.append(Path(ext_dir))
+
+ return dirs
+
+
+@lru_cache
+def _get_cn_models() -> list[str]:
+ """
+ Since we can't import ControlNet, we use a function that does something like
+ controlnet's `list(global_state.cn_models_names.values())`.
+ """
+ cn_model_exts = (".pt", ".pth", ".ckpt", ".safetensors")
+ dirs = get_cn_model_dirs()
+ name_filter = shared.opts.data.get("control_net_models_name_filter", "")
+ name_filter = name_filter.strip(" ").lower()
+
+ model_paths = []
+
+ for base in dirs:
+ if not base.exists():
+ continue
+
+ for p in base.rglob("*"):
+ if (
+ p.is_file()
+ and p.suffix in cn_model_exts
+ and cn_model_regex.search(p.name)
+ ):
+ if name_filter and name_filter not in p.name.lower():
+ continue
+ model_paths.append(p)
+ model_paths.sort(key=lambda p: p.name)
+
+ models = []
+ for p in model_paths:
+ model_hash = sd_models.model_hash(p)
+ name = f"{p.stem} [{model_hash}]"
+ models.append(name)
+ return models
+
+
+def get_cn_models() -> list[str]:
+ if controlnet_exists:
+ return _get_cn_models()
+ return []
diff --git a/adetailer/controlnet_ext/restore.py b/adetailer/controlnet_ext/restore.py
new file mode 100644
index 0000000000000000000000000000000000000000..5b9bfa6292c46c2f940c1723411494584627e9d8
--- /dev/null
+++ b/adetailer/controlnet_ext/restore.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+from contextlib import contextmanager
+
+from modules import img2img, processing, shared
+
+
+def cn_restore_unet_hook(p, cn_latest_network):
+ if cn_latest_network is not None:
+ unet = p.sd_model.model.diffusion_model
+ cn_latest_network.restore(unet)
+
+
+class CNHijackRestore:
+ def __init__(self):
+ self.process = hasattr(processing, "__controlnet_original_process_images_inner")
+ self.img2img = hasattr(img2img, "__controlnet_original_process_batch")
+
+ def __enter__(self):
+ if self.process:
+ self.orig_process = processing.process_images_inner
+ processing.process_images_inner = getattr(
+ processing, "__controlnet_original_process_images_inner"
+ )
+ if self.img2img:
+ self.orig_img2img = img2img.process_batch
+ img2img.process_batch = getattr(
+ img2img, "__controlnet_original_process_batch"
+ )
+
+ def __exit__(self, *args, **kwargs):
+ if self.process:
+ processing.process_images_inner = self.orig_process
+ if self.img2img:
+ img2img.process_batch = self.orig_img2img
+
+
+@contextmanager
+def cn_allow_script_control():
+ orig = False
+ if "control_net_allow_script_control" in shared.opts.data:
+ try:
+ orig = shared.opts.data["control_net_allow_script_control"]
+ shared.opts.data["control_net_allow_script_control"] = True
+ yield
+ finally:
+ shared.opts.data["control_net_allow_script_control"] = orig
+ else:
+ yield
diff --git a/adetailer/install.py b/adetailer/install.py
new file mode 100644
index 0000000000000000000000000000000000000000..25bfba2b16762f96fc5caedc55de261d3c2523db
--- /dev/null
+++ b/adetailer/install.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import importlib.util
+import subprocess
+import sys
+from importlib.metadata import version # python >= 3.8
+
+from packaging.version import parse
+
+
+def is_installed(
+ package: str, min_version: str | None = None, max_version: str | None = None
+):
+ try:
+ spec = importlib.util.find_spec(package)
+ except ModuleNotFoundError:
+ return False
+
+ if spec is None:
+ return False
+
+ if not min_version and not max_version:
+ return True
+
+ if not min_version:
+ min_version = "0.0.0"
+ if not max_version:
+ max_version = "99999999.99999999.99999999"
+
+ if package == "google.protobuf":
+ package = "protobuf"
+
+ try:
+ pkg_version = version(package)
+ return parse(min_version) <= parse(pkg_version) <= parse(max_version)
+ except Exception:
+ return False
+
+
+def run_pip(*args):
+ subprocess.run([sys.executable, "-m", "pip", "install", *args])
+
+
+def install():
+ deps = [
+ # requirements
+ ("ultralytics", "8.0.97", None),
+ ("mediapipe", "0.10.0", None),
+ ("huggingface_hub", None, None),
+ ("pydantic", "1.10.8", None),
+ ("rich", "13.4.2", None),
+ # mediapipe
+ ("protobuf", "3.20.0", "3.20.9999"),
+ ]
+
+ for pkg, low, high in deps:
+ # https://github.com/protocolbuffers/protobuf/tree/main/python
+ name = "google.protobuf" if pkg == "protobuf" else pkg
+
+ if not is_installed(name, low, high):
+ if low and high:
+ cmd = f"{pkg}>={low},<={high}"
+ elif low:
+ cmd = f"{pkg}>={low}"
+ elif high:
+ cmd = f"{pkg}<={high}"
+ else:
+ cmd = pkg
+
+ run_pip("-U", cmd)
+
+
+try:
+ import launch
+
+ skip_install = launch.args.skip_install
+except Exception:
+ skip_install = False
+
+if not skip_install:
+ install()
diff --git a/adetailer/preload.py b/adetailer/preload.py
new file mode 100644
index 0000000000000000000000000000000000000000..10be161f22b0a5ef7083609829a21b547eae9aea
--- /dev/null
+++ b/adetailer/preload.py
@@ -0,0 +1,9 @@
+import argparse
+
+
+def preload(parser: argparse.ArgumentParser):
+ parser.add_argument(
+ "--ad-no-huggingface",
+ action="store_true",
+ help="Don't use adetailer models from huggingface",
+ )
diff --git a/adetailer/pyproject.toml b/adetailer/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..be1fc0c9bea946d9d1dabd2514f573b5117ae683
--- /dev/null
+++ b/adetailer/pyproject.toml
@@ -0,0 +1,26 @@
+[project]
+name = "adetailer"
+description = "An object detection and auto-mask extension for stable diffusion webui."
+authors = [
+ {name = "dowon", email = "ks2515@naver.com"},
+]
+requires-python = ">=3.8,<3.12"
+readme = "README.md"
+license = {text = "AGPL-3.0"}
+
+[project.urls]
+repository = "https://github.com/Bing-su/adetailer"
+
+[tool.isort]
+profile = "black"
+known_first_party = ["launch", "modules"]
+
+[tool.ruff]
+select = ["A", "B", "C4", "C90", "E", "EM", "F", "FA", "I001", "ISC", "N", "PIE", "PT", "RET", "RUF", "SIM", "UP", "W"]
+ignore = ["B008", "B905", "E501", "F401", "UP007"]
+
+[tool.ruff.isort]
+known-first-party = ["launch", "modules"]
+
+[tool.ruff.per-file-ignores]
+"sd_webui/*.py" = ["B027", "F403"]
diff --git a/adetailer/scripts/!adetailer.py b/adetailer/scripts/!adetailer.py
new file mode 100644
index 0000000000000000000000000000000000000000..ed5de1a690068494491a1c485208ac59bd5af0e4
--- /dev/null
+++ b/adetailer/scripts/!adetailer.py
@@ -0,0 +1,784 @@
+from __future__ import annotations
+
+import os
+import platform
+import re
+import sys
+import traceback
+from contextlib import contextmanager
+from copy import copy, deepcopy
+from functools import partial
+from pathlib import Path
+from textwrap import dedent
+from typing import Any
+
+import gradio as gr
+import torch
+from rich import print
+
+import modules
+from adetailer import (
+ AFTER_DETAILER,
+ __version__,
+ get_models,
+ mediapipe_predict,
+ ultralytics_predict,
+)
+from adetailer.args import ALL_ARGS, BBOX_SORTBY, ADetailerArgs, EnableChecker
+from adetailer.common import PredictOutput
+from adetailer.mask import filter_by_ratio, mask_preprocess, sort_bboxes
+from adetailer.traceback import rich_traceback
+from adetailer.ui import adui, ordinal, suffix
+from controlnet_ext import ControlNetExt, controlnet_exists, get_cn_models
+from controlnet_ext.restore import (
+ CNHijackRestore,
+ cn_allow_script_control,
+ cn_restore_unet_hook,
+)
+from sd_webui import images, safe, script_callbacks, scripts, shared
+from sd_webui.devices import NansException
+from sd_webui.paths import data_path, models_path
+from sd_webui.processing import (
+ Processed,
+ StableDiffusionProcessingImg2Img,
+ create_infotext,
+ process_images,
+)
+from sd_webui.shared import cmd_opts, opts, state
+
+no_huggingface = getattr(cmd_opts, "ad_no_huggingface", False)
+adetailer_dir = Path(models_path, "adetailer")
+model_mapping = get_models(adetailer_dir, huggingface=not no_huggingface)
+txt2img_submit_button = img2img_submit_button = None
+SCRIPT_DEFAULT = "dynamic_prompting,dynamic_thresholding,wildcard_recursive,wildcards,lora_block_weight"
+
+if (
+ not adetailer_dir.exists()
+ and adetailer_dir.parent.exists()
+ and os.access(adetailer_dir.parent, os.W_OK)
+):
+ adetailer_dir.mkdir()
+
+print(
+ f"[-] ADetailer initialized. version: {__version__}, num models: {len(model_mapping)}"
+)
+
+
+@contextmanager
+def change_torch_load():
+ orig = torch.load
+ try:
+ torch.load = safe.unsafe_torch_load
+ yield
+ finally:
+ torch.load = orig
+
+
+@contextmanager
+def pause_total_tqdm():
+ orig = opts.data.get("multiple_tqdm", True)
+ try:
+ opts.data["multiple_tqdm"] = False
+ yield
+ finally:
+ opts.data["multiple_tqdm"] = orig
+
+
+@contextmanager
+def preseve_prompts(p):
+ all_pt = copy(p.all_prompts)
+ all_ng = copy(p.all_negative_prompts)
+ try:
+ yield
+ finally:
+ p.all_prompts = all_pt
+ p.all_negative_prompts = all_ng
+
+
+class AfterDetailerScript(scripts.Script):
+ def __init__(self):
+ super().__init__()
+ self.ultralytics_device = self.get_ultralytics_device()
+
+ self.controlnet_ext = None
+ self.cn_script = None
+ self.cn_latest_network = None
+
+ def __repr__(self):
+ return f"{self.__class__.__name__}(version={__version__})"
+
+ def title(self):
+ return AFTER_DETAILER
+
+ def show(self, is_img2img):
+ return scripts.AlwaysVisible
+
+ def ui(self, is_img2img):
+ num_models = opts.data.get("ad_max_models", 2)
+ model_list = list(model_mapping.keys())
+
+ components, infotext_fields = adui(
+ num_models,
+ is_img2img,
+ model_list,
+ txt2img_submit_button,
+ img2img_submit_button,
+ )
+
+ self.infotext_fields = infotext_fields
+ return components
+
+ def init_controlnet_ext(self) -> None:
+ if self.controlnet_ext is not None:
+ return
+ self.controlnet_ext = ControlNetExt()
+
+ if controlnet_exists:
+ try:
+ self.controlnet_ext.init_controlnet()
+ except ImportError:
+ error = traceback.format_exc()
+ print(
+ f"[-] ADetailer: ControlNetExt init failed:\n{error}",
+ file=sys.stderr,
+ )
+
+ def update_controlnet_args(self, p, args: ADetailerArgs) -> None:
+ if self.controlnet_ext is None:
+ self.init_controlnet_ext()
+
+ if (
+ self.controlnet_ext is not None
+ and self.controlnet_ext.cn_available
+ and args.ad_controlnet_model != "None"
+ ):
+ self.controlnet_ext.update_scripts_args(
+ p,
+ model=args.ad_controlnet_model,
+ module=args.ad_controlnet_module,
+ weight=args.ad_controlnet_weight,
+ guidance_start=args.ad_controlnet_guidance_start,
+ guidance_end=args.ad_controlnet_guidance_end,
+ )
+
+ def is_ad_enabled(self, *args_) -> bool:
+ arg_list = [arg for arg in args_ if isinstance(arg, dict)]
+ if not args_ or not arg_list or not isinstance(args_[0], (bool, dict)):
+ message = f"""
+ [-] ADetailer: Invalid arguments passed to ADetailer.
+ input: {args_!r}
+ """
+ raise ValueError(dedent(message))
+ enable = args_[0] if isinstance(args_[0], bool) else True
+ checker = EnableChecker(enable=enable, arg_list=arg_list)
+ return checker.is_enabled()
+
+ def get_args(self, p, *args_) -> list[ADetailerArgs]:
+ """
+ `args_` is at least 1 in length by `is_ad_enabled` immediately above
+ """
+ args = [arg for arg in args_ if isinstance(arg, dict)]
+
+ if not args:
+ message = f"[-] ADetailer: Invalid arguments passed to ADetailer: {args_!r}"
+ raise ValueError(message)
+
+ if hasattr(p, "adetailer_xyz"):
+ args[0].update(p.adetailer_xyz)
+
+ all_inputs = []
+
+ for n, arg_dict in enumerate(args, 1):
+ try:
+ inp = ADetailerArgs(**arg_dict)
+ except ValueError as e:
+ msgs = [
+ f"[-] ADetailer: ValidationError when validating {ordinal(n)} arguments: {e}\n"
+ ]
+ for attr in ALL_ARGS.attrs:
+ arg = arg_dict.get(attr)
+ dtype = type(arg)
+ arg = "DEFAULT" if arg is None else repr(arg)
+ msgs.append(f" {attr}: {arg} ({dtype})")
+ raise ValueError("\n".join(msgs)) from e
+
+ all_inputs.append(inp)
+
+ return all_inputs
+
+ def extra_params(self, arg_list: list[ADetailerArgs]) -> dict:
+ params = {}
+ for n, args in enumerate(arg_list):
+ params.update(args.extra_params(suffix=suffix(n)))
+ params["ADetailer version"] = __version__
+ return params
+
+ @staticmethod
+ def get_ultralytics_device() -> str:
+ if "adetailer" in shared.cmd_opts.use_cpu:
+ return "cpu"
+
+ if platform.system() == "Darwin":
+ return ""
+
+ if any(getattr(cmd_opts, vram, False) for vram in ["lowvram", "medvram"]):
+ return "cpu"
+
+ return ""
+
+ def prompt_blank_replacement(
+ self, all_prompts: list[str], i: int, default: str
+ ) -> str:
+ if not all_prompts:
+ return default
+ if i < len(all_prompts):
+ return all_prompts[i]
+ j = i % len(all_prompts)
+ return all_prompts[j]
+
+ def _get_prompt(
+ self, ad_prompt: str, all_prompts: list[str], i: int, default: str
+ ) -> list[str]:
+ prompts = re.split(r"\s*\[SEP\]\s*", ad_prompt)
+ blank_replacement = self.prompt_blank_replacement(all_prompts, i, default)
+ for n in range(len(prompts)):
+ if not prompts[n]:
+ prompts[n] = blank_replacement
+ return prompts
+
+ def get_prompt(self, p, args: ADetailerArgs) -> tuple[list[str], list[str]]:
+ i = p._ad_idx
+
+ prompt = self._get_prompt(args.ad_prompt, p.all_prompts, i, p.prompt)
+ negative_prompt = self._get_prompt(
+ args.ad_negative_prompt, p.all_negative_prompts, i, p.negative_prompt
+ )
+
+ return prompt, negative_prompt
+
+ def get_seed(self, p) -> tuple[int, int]:
+ i = p._ad_idx
+
+ if not p.all_seeds:
+ seed = p.seed
+ elif i < len(p.all_seeds):
+ seed = p.all_seeds[i]
+ else:
+ j = i % len(p.all_seeds)
+ seed = p.all_seeds[j]
+
+ if not p.all_subseeds:
+ subseed = p.subseed
+ elif i < len(p.all_subseeds):
+ subseed = p.all_subseeds[i]
+ else:
+ j = i % len(p.all_subseeds)
+ subseed = p.all_subseeds[j]
+
+ return seed, subseed
+
+ def get_width_height(self, p, args: ADetailerArgs) -> tuple[int, int]:
+ if args.ad_use_inpaint_width_height:
+ width = args.ad_inpaint_width
+ height = args.ad_inpaint_height
+ else:
+ width = p.width
+ height = p.height
+
+ return width, height
+
+ def get_steps(self, p, args: ADetailerArgs) -> int:
+ if args.ad_use_steps:
+ return args.ad_steps
+ return p.steps
+
+ def get_cfg_scale(self, p, args: ADetailerArgs) -> float:
+ if args.ad_use_cfg_scale:
+ return args.ad_cfg_scale
+ return p.cfg_scale
+
+ def get_initial_noise_multiplier(self, p, args: ADetailerArgs) -> float | None:
+ if args.ad_use_noise_multiplier:
+ return args.ad_noise_multiplier
+ return None
+
+ def infotext(self, p) -> str:
+ return create_infotext(
+ p, p.all_prompts, p.all_seeds, p.all_subseeds, None, 0, 0
+ )
+
+ def write_params_txt(self, p) -> None:
+ infotext = self.infotext(p)
+ params_txt = Path(data_path, "params.txt")
+ params_txt.write_text(infotext, encoding="utf-8")
+
+ def script_filter(self, p, args: ADetailerArgs):
+ script_runner = copy(p.scripts)
+ script_args = deepcopy(p.script_args)
+ self.disable_controlnet_units(script_args)
+
+ ad_only_seleted_scripts = opts.data.get("ad_only_seleted_scripts", True)
+ if not ad_only_seleted_scripts:
+ return script_runner, script_args
+
+ ad_script_names = opts.data.get("ad_script_names", SCRIPT_DEFAULT)
+ script_names_set = {
+ name
+ for script_name in ad_script_names.split(",")
+ for name in (script_name, script_name.strip())
+ }
+
+ if args.ad_controlnet_model != "None":
+ script_names_set.add("controlnet")
+
+ filtered_alwayson = []
+ for script_object in script_runner.alwayson_scripts:
+ filepath = script_object.filename
+ filename = Path(filepath).stem
+ if filename in script_names_set:
+ filtered_alwayson.append(script_object)
+ if filename == "controlnet":
+ self.cn_script = script_object
+ self.cn_latest_network = script_object.latest_network
+
+ script_runner.alwayson_scripts = filtered_alwayson
+ return script_runner, script_args
+
+ def disable_controlnet_units(self, script_args: list[Any]) -> None:
+ for obj in script_args:
+ if "controlnet" in obj.__class__.__name__.lower():
+ if hasattr(obj, "enabled"):
+ obj.enabled = False
+ if hasattr(obj, "input_mode"):
+ obj.input_mode = getattr(obj.input_mode, "SIMPLE", "simple")
+
+ elif isinstance(obj, dict) and "module" in obj:
+ obj["enabled"] = False
+
+ def get_i2i_p(self, p, args: ADetailerArgs, image):
+ seed, subseed = self.get_seed(p)
+ width, height = self.get_width_height(p, args)
+ steps = self.get_steps(p, args)
+ cfg_scale = self.get_cfg_scale(p, args)
+ initial_noise_multiplier = self.get_initial_noise_multiplier(p, args)
+
+ sampler_name = p.sampler_name
+ if sampler_name in ["PLMS", "UniPC"]:
+ sampler_name = "Euler"
+
+ i2i = StableDiffusionProcessingImg2Img(
+ init_images=[image],
+ resize_mode=0,
+ denoising_strength=args.ad_denoising_strength,
+ mask=None,
+ mask_blur=args.ad_mask_blur,
+ inpainting_fill=1,
+ inpaint_full_res=args.ad_inpaint_only_masked,
+ inpaint_full_res_padding=args.ad_inpaint_only_masked_padding,
+ inpainting_mask_invert=0,
+ initial_noise_multiplier=initial_noise_multiplier,
+ sd_model=p.sd_model,
+ outpath_samples=p.outpath_samples,
+ outpath_grids=p.outpath_grids,
+ prompt="", # replace later
+ negative_prompt="",
+ styles=p.styles,
+ seed=seed,
+ subseed=subseed,
+ subseed_strength=p.subseed_strength,
+ seed_resize_from_h=p.seed_resize_from_h,
+ seed_resize_from_w=p.seed_resize_from_w,
+ sampler_name=sampler_name,
+ batch_size=1,
+ n_iter=1,
+ steps=steps,
+ cfg_scale=cfg_scale,
+ width=width,
+ height=height,
+ restore_faces=args.ad_restore_face,
+ tiling=p.tiling,
+ extra_generation_params=p.extra_generation_params,
+ do_not_save_samples=True,
+ do_not_save_grid=True,
+ )
+
+ i2i.cached_c = [None, None]
+ i2i.cached_uc = [None, None]
+ i2i.scripts, i2i.script_args = self.script_filter(p, args)
+ i2i._disable_adetailer = True
+
+ if args.ad_controlnet_model != "None":
+ self.update_controlnet_args(i2i, args)
+ else:
+ i2i.control_net_enabled = False
+
+ return i2i
+
+ def save_image(self, p, image, *, condition: str, suffix: str) -> None:
+ i = p._ad_idx
+ if p.all_prompts:
+ i %= len(p.all_prompts)
+ save_prompt = p.all_prompts[i]
+ else:
+ save_prompt = p.prompt
+ seed, _ = self.get_seed(p)
+
+ if opts.data.get(condition, False):
+ images.save_image(
+ image=image,
+ path=p.outpath_samples,
+ basename="",
+ seed=seed,
+ prompt=save_prompt,
+ extension=opts.samples_format,
+ info=self.infotext(p),
+ p=p,
+ suffix=suffix,
+ )
+
+ def get_ad_model(self, name: str):
+ if name not in model_mapping:
+ msg = f"[-] ADetailer: Model {name!r} not found. Available models: {list(model_mapping.keys())}"
+ raise ValueError(msg)
+ return model_mapping[name]
+
+ def sort_bboxes(self, pred: PredictOutput) -> PredictOutput:
+ sortby = opts.data.get("ad_bbox_sortby", BBOX_SORTBY[0])
+ sortby_idx = BBOX_SORTBY.index(sortby)
+ return sort_bboxes(pred, sortby_idx)
+
+ def pred_preprocessing(self, pred: PredictOutput, args: ADetailerArgs):
+ pred = filter_by_ratio(
+ pred, low=args.ad_mask_min_ratio, high=args.ad_mask_max_ratio
+ )
+ pred = self.sort_bboxes(pred)
+ return mask_preprocess(
+ pred.masks,
+ kernel=args.ad_dilate_erode,
+ x_offset=args.ad_x_offset,
+ y_offset=args.ad_y_offset,
+ merge_invert=args.ad_mask_merge_invert,
+ )
+
+ def i2i_prompts_replace(
+ self, i2i, prompts: list[str], negative_prompts: list[str], j: int
+ ) -> None:
+ i1 = min(j, len(prompts) - 1)
+ i2 = min(j, len(negative_prompts) - 1)
+ prompt = prompts[i1]
+ negative_prompt = negative_prompts[i2]
+ i2i.prompt = prompt
+ i2i.negative_prompt = negative_prompt
+
+ @staticmethod
+ def compare_prompt(p, processed, n: int = 0):
+ if p.prompt != processed.all_prompts[0]:
+ print(
+ f"[-] ADetailer: applied {ordinal(n + 1)} ad_prompt: {processed.all_prompts[0]!r}"
+ )
+
+ if p.negative_prompt != processed.all_negative_prompts[0]:
+ print(
+ f"[-] ADetailer: applied {ordinal(n + 1)} ad_negative_prompt: {processed.all_negative_prompts[0]!r}"
+ )
+
+ def need_call_process(self, p) -> bool:
+ i = p._ad_idx
+ bs = p.batch_size
+ return i % bs == bs - 1
+
+ def need_call_postprocess(self, p) -> bool:
+ i = p._ad_idx
+ bs = p.batch_size
+ return i % bs == 0
+
+ @rich_traceback
+ def process(self, p, *args_):
+ if getattr(p, "_disable_adetailer", False):
+ return
+
+ if self.is_ad_enabled(*args_):
+ arg_list = self.get_args(p, *args_)
+ extra_params = self.extra_params(arg_list)
+ p.extra_generation_params.update(extra_params)
+
+ def _postprocess_image(self, p, pp, args: ADetailerArgs, *, n: int = 0) -> bool:
+ """
+ Returns
+ -------
+ bool
+
+ `True` if image was processed, `False` otherwise.
+ """
+ if state.interrupted:
+ return False
+
+ i = p._ad_idx
+
+ i2i = self.get_i2i_p(p, args, pp.image)
+ seed, subseed = self.get_seed(p)
+ ad_prompts, ad_negatives = self.get_prompt(p, args)
+
+ is_mediapipe = args.ad_model.lower().startswith("mediapipe")
+
+ kwargs = {}
+ if is_mediapipe:
+ predictor = mediapipe_predict
+ ad_model = args.ad_model
+ else:
+ predictor = ultralytics_predict
+ ad_model = self.get_ad_model(args.ad_model)
+ kwargs["device"] = self.ultralytics_device
+
+ with change_torch_load():
+ pred = predictor(ad_model, pp.image, args.ad_confidence, **kwargs)
+
+ masks = self.pred_preprocessing(pred, args)
+
+ if not masks:
+ print(
+ f"[-] ADetailer: nothing detected on image {i + 1} with {ordinal(n + 1)} settings."
+ )
+ return False
+
+ self.save_image(
+ p,
+ pred.preview,
+ condition="ad_save_previews",
+ suffix="-ad-preview" + suffix(n, "-"),
+ )
+
+ steps = len(masks)
+ processed = None
+ state.job_count += steps
+
+ if is_mediapipe:
+ print(f"mediapipe: {steps} detected.")
+
+ _user_pt = p.prompt
+ _user_ng = p.negative_prompt
+
+ p2 = copy(i2i)
+ for j in range(steps):
+ p2.image_mask = masks[j]
+ self.i2i_prompts_replace(p2, ad_prompts, ad_negatives, j)
+
+ if re.match(r"^\s*\[SKIP\]\s*$", p2.prompt):
+ continue
+
+ p2.seed = seed + j
+ p2.subseed = subseed + j
+
+ try:
+ processed = process_images(p2)
+ except NansException as e:
+ msg = f"[-] ADetailer: 'NansException' occurred with {ordinal(n + 1)} settings.\n{e}"
+ print(msg, file=sys.stderr)
+ continue
+ finally:
+ p2.close()
+
+ self.compare_prompt(p2, processed, n=n)
+ p2 = copy(i2i)
+ p2.init_images = [processed.images[0]]
+
+ if processed is not None:
+ pp.image = processed.images[0]
+ return True
+
+ return False
+
+ @rich_traceback
+ def postprocess_image(self, p, pp, *args_):
+ if getattr(p, "_disable_adetailer", False):
+ return
+
+ if not self.is_ad_enabled(*args_):
+ return
+
+ p._ad_idx = getattr(p, "_ad_idx", -1) + 1
+ init_image = copy(pp.image)
+ arg_list = self.get_args(p, *args_)
+
+ if p.scripts is not None and self.need_call_postprocess(p):
+ dummy = Processed(p, [], p.seed, "")
+ with preseve_prompts(p):
+ p.scripts.postprocess(copy(p), dummy)
+
+ is_processed = False
+ with CNHijackRestore(), pause_total_tqdm(), cn_allow_script_control():
+ for n, args in enumerate(arg_list):
+ if args.ad_model == "None":
+ continue
+ is_processed |= self._postprocess_image(p, pp, args, n=n)
+
+ if is_processed:
+ self.save_image(
+ p, init_image, condition="ad_save_images_before", suffix="-ad-before"
+ )
+
+ if p.scripts is not None and self.need_call_process(p):
+ with preseve_prompts(p):
+ p.scripts.process(copy(p))
+
+ try:
+ ia = p._ad_idx
+ lenp = len(p.all_prompts)
+ if ia % lenp == lenp - 1:
+ self.write_params_txt(p)
+ except Exception:
+ pass
+
+
+def on_after_component(component, **_kwargs):
+ global txt2img_submit_button, img2img_submit_button
+ if getattr(component, "elem_id", None) == "txt2img_generate":
+ txt2img_submit_button = component
+ return
+
+ if getattr(component, "elem_id", None) == "img2img_generate":
+ img2img_submit_button = component
+
+
+def on_ui_settings():
+ section = ("ADetailer", AFTER_DETAILER)
+ shared.opts.add_option(
+ "ad_max_models",
+ shared.OptionInfo(
+ default=2,
+ label="Max models",
+ component=gr.Slider,
+ component_args={"minimum": 1, "maximum": 10, "step": 1},
+ section=section,
+ ),
+ )
+
+ shared.opts.add_option(
+ "ad_save_previews",
+ shared.OptionInfo(False, "Save mask previews", section=section),
+ )
+
+ shared.opts.add_option(
+ "ad_save_images_before",
+ shared.OptionInfo(False, "Save images before ADetailer", section=section),
+ )
+
+ shared.opts.add_option(
+ "ad_only_seleted_scripts",
+ shared.OptionInfo(
+ True, "Apply only selected scripts to ADetailer", section=section
+ ),
+ )
+
+ textbox_args = {
+ "placeholder": "comma-separated list of script names",
+ "interactive": True,
+ }
+
+ shared.opts.add_option(
+ "ad_script_names",
+ shared.OptionInfo(
+ default=SCRIPT_DEFAULT,
+ label="Script names to apply to ADetailer (separated by comma)",
+ component=gr.Textbox,
+ component_args=textbox_args,
+ section=section,
+ ),
+ )
+
+ shared.opts.add_option(
+ "ad_bbox_sortby",
+ shared.OptionInfo(
+ default="None",
+ label="Sort bounding boxes by",
+ component=gr.Radio,
+ component_args={"choices": BBOX_SORTBY},
+ section=section,
+ ),
+ )
+
+
+# xyz_grid
+
+
+def make_axis_on_xyz_grid():
+ xyz_grid = None
+ for script in scripts.scripts_data:
+ if script.script_class.__module__ == "xyz_grid.py":
+ xyz_grid = script.module
+ break
+
+ if xyz_grid is None:
+ return
+
+ model_list = ["None", *model_mapping.keys()]
+
+ def set_value(p, x, xs, *, field: str):
+ if not hasattr(p, "adetailer_xyz"):
+ p.adetailer_xyz = {}
+ p.adetailer_xyz[field] = x
+
+ axis = [
+ xyz_grid.AxisOption(
+ "[ADetailer] ADetailer model 1st",
+ str,
+ partial(set_value, field="ad_model"),
+ choices=lambda: model_list,
+ ),
+ xyz_grid.AxisOption(
+ "[ADetailer] ADetailer prompt 1st",
+ str,
+ partial(set_value, field="ad_prompt"),
+ ),
+ xyz_grid.AxisOption(
+ "[ADetailer] ADetailer negative prompt 1st",
+ str,
+ partial(set_value, field="ad_negative_prompt"),
+ ),
+ xyz_grid.AxisOption(
+ "[ADetailer] Mask erosion / dilation 1st",
+ int,
+ partial(set_value, field="ad_dilate_erode"),
+ ),
+ xyz_grid.AxisOption(
+ "[ADetailer] Inpaint denoising strength 1st",
+ float,
+ partial(set_value, field="ad_denoising_strength"),
+ ),
+ xyz_grid.AxisOption(
+ "[ADetailer] Inpaint only masked 1st",
+ str,
+ partial(set_value, field="ad_inpaint_only_masked"),
+ choices=lambda: ["True", "False"],
+ ),
+ xyz_grid.AxisOption(
+ "[ADetailer] Inpaint only masked padding 1st",
+ int,
+ partial(set_value, field="ad_inpaint_only_masked_padding"),
+ ),
+ xyz_grid.AxisOption(
+ "[ADetailer] ControlNet model 1st",
+ str,
+ partial(set_value, field="ad_controlnet_model"),
+ choices=lambda: ["None", *get_cn_models()],
+ ),
+ ]
+
+ if not any(x.label.startswith("[ADetailer]") for x in xyz_grid.axis_options):
+ xyz_grid.axis_options.extend(axis)
+
+
+def on_before_ui():
+ try:
+ make_axis_on_xyz_grid()
+ except Exception:
+ error = traceback.format_exc()
+ print(
+ f"[-] ADetailer: xyz_grid error:\n{error}",
+ file=sys.stderr,
+ )
+
+
+script_callbacks.on_ui_settings(on_ui_settings)
+script_callbacks.on_after_component(on_after_component)
+script_callbacks.on_before_ui(on_before_ui)
diff --git a/adetailer/sd_webui/__init__.py b/adetailer/sd_webui/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/adetailer/sd_webui/devices.py b/adetailer/sd_webui/devices.py
new file mode 100644
index 0000000000000000000000000000000000000000..51d0569a8ea9dda869ac43ac338835e2fbb27782
--- /dev/null
+++ b/adetailer/sd_webui/devices.py
@@ -0,0 +1,11 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+
+ class NansException(Exception): # noqa: N818
+ pass
+
+else:
+ from modules.devices import NansException
diff --git a/adetailer/sd_webui/images.py b/adetailer/sd_webui/images.py
new file mode 100644
index 0000000000000000000000000000000000000000..b4a2dbce09ad3583f3aac4624c969657103c145e
--- /dev/null
+++ b/adetailer/sd_webui/images.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from PIL import Image, PngImagePlugin
+
+ from sd_webui.processing import StableDiffusionProcessing
+
+ def save_image(
+ image: Image.Image,
+ path: str,
+ basename: str,
+ seed: int | None = None,
+ prompt: str = "",
+ extension: str = "png",
+ info: str | PngImagePlugin.iTXt = "",
+ short_filename: bool = False,
+ no_prompt: bool = False,
+ grid: bool = False,
+ pnginfo_section_name: str = "parameters",
+ p: StableDiffusionProcessing | None = None,
+ existing_info: dict | None = None,
+ forced_filename: str | None = None,
+ suffix: str = "",
+ save_to_dirs: bool = False,
+ ) -> tuple[str, str | None]:
+ """Save an image.
+
+ Args:
+ image (`PIL.Image`):
+ The image to be saved.
+ path (`str`):
+ The directory to save the image. Note, the option `save_to_dirs` will make the image to be saved into a sub directory.
+ basename (`str`):
+ The base filename which will be applied to `filename pattern`.
+ seed, prompt, short_filename,
+ extension (`str`):
+ Image file extension, default is `png`.
+ pngsectionname (`str`):
+ Specify the name of the section which `info` will be saved in.
+ info (`str` or `PngImagePlugin.iTXt`):
+ PNG info chunks.
+ existing_info (`dict`):
+ Additional PNG info. `existing_info == {pngsectionname: info, ...}`
+ no_prompt:
+ TODO I don't know its meaning.
+ p (`StableDiffusionProcessing`)
+ forced_filename (`str`):
+ If specified, `basename` and filename pattern will be ignored.
+ save_to_dirs (bool):
+ If true, the image will be saved into a subdirectory of `path`.
+
+ Returns: (fullfn, txt_fullfn)
+ fullfn (`str`):
+ The full path of the saved imaged.
+ txt_fullfn (`str` or None):
+ If a text file is saved for this image, this will be its full path. Otherwise None.
+ """
+
+else:
+ from modules.images import save_image
diff --git a/adetailer/sd_webui/paths.py b/adetailer/sd_webui/paths.py
new file mode 100644
index 0000000000000000000000000000000000000000..8050ba080788f291a3c717016798f3c6531e655e
--- /dev/null
+++ b/adetailer/sd_webui/paths.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import os
+
+ models_path = os.path.join(os.path.dirname(__file__), "1")
+ script_path = os.path.join(os.path.dirname(__file__), "2")
+ data_path = os.path.join(os.path.dirname(__file__), "3")
+ extensions_dir = os.path.join(os.path.dirname(__file__), "4")
+ extensions_builtin_dir = os.path.join(os.path.dirname(__file__), "5")
+else:
+ from modules.paths import data_path, models_path, script_path
diff --git a/adetailer/sd_webui/processing.py b/adetailer/sd_webui/processing.py
new file mode 100644
index 0000000000000000000000000000000000000000..e65d99f9ab44cb748075455d6ea1c02a481b6f0b
--- /dev/null
+++ b/adetailer/sd_webui/processing.py
@@ -0,0 +1,176 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from dataclasses import dataclass, field
+ from typing import Any, Callable
+
+ import numpy as np
+ import torch
+ from PIL import Image
+
+ def _image():
+ return Image.new("L", (512, 512))
+
+ @dataclass
+ class StableDiffusionProcessing:
+ sd_model: torch.nn.Module = field(default_factory=lambda: torch.nn.Linear(1, 1))
+ outpath_samples: str = ""
+ outpath_grids: str = ""
+ prompt: str = ""
+ prompt_for_display: str = ""
+ negative_prompt: str = ""
+ styles: list[str] = field(default_factory=list)
+ seed: int = -1
+ subseed: int = -1
+ subseed_strength: float = 0.0
+ seed_resize_from_h: int = -1
+ seed_resize_from_w: int = -1
+ sampler_name: str | None = None
+ batch_size: int = 1
+ n_iter: int = 1
+ steps: int = 50
+ cfg_scale: float = 7.0
+ width: int = 512
+ height: int = 512
+ restore_faces: bool = False
+ tiling: bool = False
+ do_not_save_samples: bool = False
+ do_not_save_grid: bool = False
+ extra_generation_params: dict[str, Any] = field(default_factory=dict)
+ overlay_images: list[Image.Image] = field(default_factory=list)
+ eta: float = 0.0
+ do_not_reload_embeddings: bool = False
+ paste_to: tuple[int | float, ...] = (0, 0, 0, 0)
+ color_corrections: list[np.ndarray] = field(default_factory=list)
+ denoising_strength: float = 0.0
+ sampler_noise_scheduler_override: Callable | None = None
+ ddim_discretize: str = ""
+ s_min_uncond: float = 0.0
+ s_churn: float = 0.0
+ s_tmin: float = 0.0
+ s_tmax: float = 0.0
+ s_noise: float = 0.0
+ override_settings: dict[str, Any] = field(default_factory=dict)
+ override_settings_restore_afterwards: bool = False
+ is_using_inpainting_conditioning: bool = False
+ disable_extra_networks: bool = False
+ scripts: Any = None
+ script_args: list[Any] = field(default_factory=list)
+ all_prompts: list[str] = field(default_factory=list)
+ all_negative_prompts: list[str] = field(default_factory=list)
+ all_seeds: list[int] = field(default_factory=list)
+ all_subseeds: list[int] = field(default_factory=list)
+ iteration: int = 1
+ is_hr_pass: bool = False
+
+ def close(self) -> None:
+ pass
+
+ @dataclass
+ class StableDiffusionProcessingTxt2Img(StableDiffusionProcessing):
+ sampler: Callable | None = None
+ enable_hr: bool = False
+ denoising_strength: float = 0.75
+ hr_scale: float = 2.0
+ hr_upscaler: str = ""
+ hr_second_pass_steps: int = 0
+ hr_resize_x: int = 0
+ hr_resize_y: int = 0
+ hr_upscale_to_x: int = 0
+ hr_upscale_to_y: int = 0
+ width: int = 512
+ height: int = 512
+ truncate_x: int = 512
+ truncate_y: int = 512
+ applied_old_hires_behavior_to: tuple[int, int] = (512, 512)
+
+ @dataclass
+ class StableDiffusionProcessingImg2Img(StableDiffusionProcessing):
+ sampler: Callable | None = None
+ init_images: list[Image.Image] = field(default_factory=list)
+ resize_mode: int = 0
+ denoising_strength: float = 0.75
+ image_cfg_scale: float | None = None
+ init_latent: torch.Tensor | None = None
+ image_mask: Image.Image = field(default_factory=_image)
+ latent_mask: Image.Image = field(default_factory=_image)
+ mask_for_overlay: Image.Image = field(default_factory=_image)
+ mask_blur: int = 4
+ inpainting_fill: int = 0
+ inpaint_full_res: bool = True
+ inpaint_full_res_padding: int = 0
+ inpainting_mask_invert: int | bool = 0
+ initial_noise_multiplier: float = 1.0
+ mask: torch.Tensor | None = None
+ nmask: torch.Tensor | None = None
+ image_conditioning: torch.Tensor | None = None
+
+ @dataclass
+ class Processed:
+ images: list[Image.Image] = field(default_factory=list)
+ prompt: list[str] = field(default_factory=list)
+ negative_prompt: list[str] = field(default_factory=list)
+ seed: list[int] = field(default_factory=list)
+ subseed: list[int] = field(default_factory=list)
+ subseed_strength: float = 0.0
+ info: str = ""
+ comments: str = ""
+ width: int = 512
+ height: int = 512
+ sampler_name: str = ""
+ cfg_scale: float = 7.0
+ image_cfg_scale: float | None = None
+ steps: int = 50
+ batch_size: int = 1
+ restore_faces: bool = False
+ face_restoration_model: str | None = None
+ sd_model_hash: str = ""
+ seed_resize_from_w: int = -1
+ seed_resize_from_h: int = -1
+ denoising_strength: float = 0.0
+ extra_generation_params: dict[str, Any] = field(default_factory=dict)
+ index_of_first_image: int = 0
+ styles: list[str] = field(default_factory=list)
+ job_timestamp: str = ""
+ clip_skip: int = 1
+ eta: float = 0.0
+ ddim_discretize: str = ""
+ s_churn: float = 0.0
+ s_tmin: float = 0.0
+ s_tmax: float = 0.0
+ s_noise: float = 0.0
+ sampler_noise_scheduler_override: Callable | None = None
+ is_using_inpainting_conditioning: bool = False
+ all_prompts: list[str] = field(default_factory=list)
+ all_negative_prompts: list[str] = field(default_factory=list)
+ all_seeds: list[int] = field(default_factory=list)
+ all_subseeds: list[int] = field(default_factory=list)
+ infotexts: list[str] = field(default_factory=list)
+
+ def create_infotext(
+ p: StableDiffusionProcessingTxt2Img | StableDiffusionProcessingImg2Img,
+ all_prompts: list[str],
+ all_seeds: list[int],
+ all_subseeds: list[int],
+ comments: Any,
+ iteration: int = 0,
+ position_in_batch: int = 0,
+ ) -> str:
+ pass
+
+ def process_images(
+ p: StableDiffusionProcessingTxt2Img | StableDiffusionProcessingImg2Img,
+ ) -> Processed:
+ pass
+
+else:
+ from modules.processing import (
+ Processed,
+ StableDiffusionProcessing,
+ StableDiffusionProcessingImg2Img,
+ StableDiffusionProcessingTxt2Img,
+ create_infotext,
+ process_images,
+ )
diff --git a/adetailer/sd_webui/safe.py b/adetailer/sd_webui/safe.py
new file mode 100644
index 0000000000000000000000000000000000000000..c17a1b97d3ce1590d0ecfa49373a826a300404d1
--- /dev/null
+++ b/adetailer/sd_webui/safe.py
@@ -0,0 +1,10 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import torch
+
+ unsafe_torch_load = torch.load
+else:
+ from modules.safe import unsafe_torch_load
diff --git a/adetailer/sd_webui/script_callbacks.py b/adetailer/sd_webui/script_callbacks.py
new file mode 100644
index 0000000000000000000000000000000000000000..ebb3ac0507bd32a8b6f962e9796829f539cd31a1
--- /dev/null
+++ b/adetailer/sd_webui/script_callbacks.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from typing import Callable
+
+ def on_app_started(callback: Callable):
+ pass
+
+ def on_ui_settings(callback: Callable):
+ pass
+
+ def on_after_component(callback: Callable):
+ pass
+
+ def on_before_ui(callback: Callable):
+ pass
+
+else:
+ from modules.script_callbacks import (
+ on_after_component,
+ on_app_started,
+ on_before_ui,
+ on_ui_settings,
+ )
diff --git a/adetailer/sd_webui/scripts.py b/adetailer/sd_webui/scripts.py
new file mode 100644
index 0000000000000000000000000000000000000000..e515bbb70e5e6f880892b80a9af62f96efe87dea
--- /dev/null
+++ b/adetailer/sd_webui/scripts.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from abc import ABC, abstractmethod
+ from collections import namedtuple
+ from dataclasses import dataclass
+ from typing import Any
+
+ import gradio as gr
+ from PIL import Image
+
+ from sd_webui.processing import (
+ Processed,
+ StableDiffusionProcessingImg2Img,
+ StableDiffusionProcessingTxt2Img,
+ )
+
+ SDPType = StableDiffusionProcessingImg2Img | StableDiffusionProcessingTxt2Img
+ AlwaysVisible = object()
+
+ @dataclass
+ class PostprocessImageArgs:
+ image: Image.Image
+
+ class Script(ABC):
+ filename: str
+ args_from: int
+ args_to: int
+ alwayson: bool
+
+ is_txt2img: bool
+ is_img2img: bool
+
+ group: gr.Group
+ infotext_fields: list[tuple[str, str]]
+ paste_field_names: list[str]
+
+ @abstractmethod
+ def title(self):
+ raise NotImplementedError
+
+ def ui(self, is_img2img: bool):
+ pass
+
+ def show(self, is_img2img: bool):
+ return True
+
+ def run(self, p: SDPType, *args):
+ pass
+
+ def process(self, p: SDPType, *args):
+ pass
+
+ def before_process_batch(self, p: SDPType, *args, **kwargs):
+ pass
+
+ def process_batch(self, p: SDPType, *args, **kwargs):
+ pass
+
+ def postprocess_batch(self, p: SDPType, *args, **kwargs):
+ pass
+
+ def postprocess_image(self, p: SDPType, pp: PostprocessImageArgs, *args):
+ pass
+
+ def postprocess(self, p: SDPType, processed: Processed, *args):
+ pass
+
+ def before_component(self, component, **kwargs):
+ pass
+
+ def after_component(self, component, **kwargs):
+ pass
+
+ def describe(self):
+ return ""
+
+ def elem_id(self, item_id: Any) -> str:
+ pass
+
+ ScriptClassData = namedtuple(
+ "ScriptClassData", ["script_class", "path", "basedir", "module"]
+ )
+ scripts_data: list[ScriptClassData] = []
+
+else:
+ from modules.scripts import (
+ AlwaysVisible,
+ PostprocessImageArgs,
+ Script,
+ scripts_data,
+ )
diff --git a/adetailer/sd_webui/shared.py b/adetailer/sd_webui/shared.py
new file mode 100644
index 0000000000000000000000000000000000000000..18b0cd0801e652340c86dc6e7074fb0c92b99cc4
--- /dev/null
+++ b/adetailer/sd_webui/shared.py
@@ -0,0 +1,66 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import argparse
+ from dataclasses import dataclass
+ from typing import Any, Callable
+
+ import torch
+ from PIL import Image
+
+ @dataclass
+ class State:
+ skipped: bool = False
+ interrupted: bool = False
+ job: str = ""
+ job_no: int = 0
+ job_count: int = 0
+ processing_has_refined_job_count: bool = False
+ job_timestamp: str = "0"
+ sampling_step: int = 0
+ sampling_steps: int = 0
+ current_latent: torch.Tensor | None = None
+ current_image: Image.Image | None = None
+ current_image_sampling_step: int = 0
+ id_live_preview: int = 0
+ textinfo: str | None = None
+ time_start: float | None = None
+ need_restart: bool = False
+ server_start: float | None = None
+
+ @dataclass
+ class OptionInfo:
+ default: Any = None
+ label: str = ""
+ component: Any = None
+ component_args: Callable[[], dict] | dict[str, Any] | None = None
+ onchange: Callable[[], None] | None = None
+ section: tuple[str, str] | None = None
+ refresh: Callable[[], None] | None = None
+
+ class Option:
+ data_labels: dict[str, OptionInfo]
+
+ def __init__(self):
+ self.data: dict[str, Any] = {}
+
+ def add_option(self, key: str, info: OptionInfo):
+ pass
+
+ def __getattr__(self, item: str):
+ if self.data is not None and item in self.data:
+ return self.data[item]
+
+ if item in self.data_labels:
+ return self.data_labels[item].default
+
+ return super().__getattribute__(item)
+
+ opts = Option()
+ cmd_opts = argparse.Namespace()
+ state = State()
+
+else:
+ from modules.shared import OptionInfo, cmd_opts, opts, state
diff --git a/openOutpaint-webUI-extension/.DS_Store b/openOutpaint-webUI-extension/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..8a885aad82a0eaaa7561b84f944ade59a3af2c73
Binary files /dev/null and b/openOutpaint-webUI-extension/.DS_Store differ
diff --git a/openOutpaint-webUI-extension/LICENSE b/openOutpaint-webUI-extension/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..2228febc5439cf0dfb8165c06a1c4cb1e671aa99
--- /dev/null
+++ b/openOutpaint-webUI-extension/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 tim h
+
+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.
diff --git a/openOutpaint-webUI-extension/README.MD b/openOutpaint-webUI-extension/README.MD
new file mode 100644
index 0000000000000000000000000000000000000000..8e0bc3d6235a689bb96c2c798570c7dd6aa6d70c
--- /dev/null
+++ b/openOutpaint-webUI-extension/README.MD
@@ -0,0 +1,22 @@
+In this repo lives a mighty handy little wrapper for adding [openOutpaint](https://github.com/zero01101/openOutpaint) to [AUTOMATIC1111 webUI](https://github.com/AUTOMATIC1111/stable-diffusion-webui) directly as a native extension.
+
+Please see the respective READMEs and wikis for each of the above projects for a more comprehensive understanding of their feature sets.
+
+This extension also adds buttons to send output from webUI txt2img and img2img tools directly to openOutpaint which will also include the prompts used for convenience.
+
+**_2023-01-23: new `--lock-oo-submodule` commandline argument if you want to roll back to a previous version of openOutpaint and keep it there - be sure to install/run openOutpaint extension at least once before enabling this flag_**
+
+**Note: Requires `--api` flag enabled in your webui-user launch script!**
+
+**_FURTHER NOTE: the commandline flag `--gradio-debug` disables custom API routes and completely breaks openOutpaint. please remove it from your COMMANDLINE_ARGS before running openOutpaint._**
+
+**_EVEN FURTHER NOTE: [PLEASE SEE DOCUMENTATION REGARDING NEW HRfix FEATURES](https://github.com/zero01101/openOutpaint/wiki/Manual#hrfix) IMPLEMENTED AS OF webUI COMMIT [ef27a18](https://github.com/AUTOMATIC1111/stable-diffusion-webui/commit/ef27a18b6b7cb1a8eebdc9b2e88d25baf2c2414d)_**
+
+
+
+### surprising incompatibilities
+
+**_COLAB USERS: you may experience issues installing openOutpaint (and other webUI extensions) - there is a workaround that has been discovered and tested against [TheLastBen's fast-stable-diffusion](https://github.com/TheLastBen/fast-stable-diffusion). Please see [this discussion](https://github.com/TheLastBen/fast-stable-diffusion/discussions/1161) containing the workaround, which requires adding a command into the final cell of the colab, as well as setting `Enable_API` to `True`._**
+
+- [microsoft editor extension for chrome/edge seems to disable the overmask slider](https://github.com/zero01101/openOutpaint/discussions/88#discussioncomment-4498341)
+- ~~[duckduckgo privacy extension for firefox breaks outpainting, resulting in pure black output](https://github.com/zero01101/openOutpaint-webUI-extension/issues/3#issuecomment-1367694000) - add an exception for your openOutpaint host (likely localhost or 127.0.0.1)~~ should be fixed as of [b128943](https://github.com/zero01101/openOutpaint/commit/b128943f0c94970600fdc1c98bfec22de619866f)
diff --git a/openOutpaint-webUI-extension/app/.DS_Store b/openOutpaint-webUI-extension/app/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..71d301a991462da5bae904bba310af1fa57a3b97
Binary files /dev/null and b/openOutpaint-webUI-extension/app/.DS_Store differ
diff --git a/openOutpaint-webUI-extension/app/.devtools/README.md b/openOutpaint-webUI-extension/app/.devtools/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..d0586e72f8ae65e84826864a1cd95cc26c375e88
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.devtools/README.md
@@ -0,0 +1,12 @@
+# openOutpaint DevTools
+
+This is a folder containing some handy scripts to help developers to automate workflows and write code in openOutpaint's standards.
+All scripts must be run from the root of the project.
+
+## `sethooks.sh` and `sethooks.ps1` scripts
+
+These scripts will setup git hooks for this project. Hooks are mainly for cache busting purposes for now. It is recommended to run this script and setup hooks before any commits are made.
+
+## `lint.sh` script
+
+Uses `npm` to install prettier and lint javascript, html and css files according to `.prettierrc.json`. This script will install node modules locally, so editors with prettier support are recommended over this script.
diff --git a/openOutpaint-webUI-extension/app/.devtools/lint.sh b/openOutpaint-webUI-extension/app/.devtools/lint.sh
new file mode 100755
index 0000000000000000000000000000000000000000..d803fcfb1b14f24d8f931e99d9acbe5a96e1a220
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.devtools/lint.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/bash
+
+if ! which npx 2>&1 > /dev/null; then
+ echo "[lint] npm/npx is not installed"
+ exit 1
+fi
+
+npx prettier > /dev/null || npm install prettier && echo "[lint] We have 'prettier'"
+npx eslint > /dev/null || npm install eslint && echo "[lint] We have 'eslint'"
+npx prettier-eslint > /dev/null || npm install prettier-eslint-cli && echo "[lint] We have 'prettier-eslint'"
+
+echo "[lint] Linting JavaScript files..."
+npx prettier-eslint --write "**/*.js"
+echo "[lint] Linting HTML files..."
+npx prettier-eslint --write "**/*.html"
+echo "[lint] Linting CSS files..."
+npx prettier-eslint --write "**/*.css"
+echo "[lint] Linting MarkDown files"
+npx prettier-eslint --write "**/*.md"
+
+echo "[lint] Finished Linting."
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/.devtools/sethooks.ps1 b/openOutpaint-webUI-extension/app/.devtools/sethooks.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..930a120238a462d90d5ae6ca2cd5673c0b16b259
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.devtools/sethooks.ps1
@@ -0,0 +1,4 @@
+git config core.eol lf
+git config core.autocrlf input
+
+git config core.hooksPath .githooks/windows
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/.devtools/sethooks.sh b/openOutpaint-webUI-extension/app/.devtools/sethooks.sh
new file mode 100755
index 0000000000000000000000000000000000000000..ddb661997bb8008b7ad5b367606cf87ca429969f
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.devtools/sethooks.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/sh
+
+git config core.hooksPath .githooks/linux
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/.devtools/updatehashes.ps1 b/openOutpaint-webUI-extension/app/.devtools/updatehashes.ps1
new file mode 100644
index 0000000000000000000000000000000000000000..993cab6e3e2ebabe5bec5d6387487feda12a498d
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.devtools/updatehashes.ps1
@@ -0,0 +1,27 @@
+# Updates html files with cache busting urls including file hashes.
+
+# Actual file processing
+$htmlfiles = Get-ChildItem -Path . -Recurse -Filter "*.html" | Where {$_.FullName -notlike "*\node_modules\*"} | Resolve-path -relative
+foreach ($htmlfile in $htmlfiles) {
+ Write-Host "[info] Processing '${htmlfile}' for cache busting..." -ForegroundColor Blue
+
+ $resfiles = (@(Get-ChildItem -Path . -Recurse -Filter "*.css") + (Get-ChildItem -Path . -Recurse -Filter "*.js")) | Resolve-Path -relative
+
+ if ($args[0] -eq "gitadd") {
+ $resfiles = (git status -s | Select-String -Pattern "[A-Z] .+") | ForEach-Object { -split $_.Line | Select-Object -Last 1 }
+ }
+
+ foreach ($resfile in $resfiles) {
+ $resfile = $resfile -replace '\\', '/' -replace '\./', ''
+ # Check if resource is used in html file
+ if ($null -ne (Select-String -Path $htmlfile -Pattern $resfile)) {
+ $hash = (Get-FileHash $resfile -Algorithm SHA1).Hash
+
+ # This is just for cache busting...
+ # If 7 first characters of SHA1 is okay for git, it should be more than enough for us
+ $hash = $hash.Substring(0, 7).ToLower()
+
+ (Get-Content -Raw -Path $htmlfile).replace('\r\n', "\n") -replace "$resfile(\?v=[a-z0-9]+)?", "${resfile}?v=$hash" | Set-Content $htmlfile
+ }
+ }
+}
diff --git a/openOutpaint-webUI-extension/app/.devtools/updatehashes.sh b/openOutpaint-webUI-extension/app/.devtools/updatehashes.sh
new file mode 100755
index 0000000000000000000000000000000000000000..e52c675224bcbbd10c60a996e733b6fd8f4c6e0d
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.devtools/updatehashes.sh
@@ -0,0 +1,128 @@
+#!/usr/bin/bash
+#
+# Updates html files with cache busting urls including file hashes.
+
+# Setup colors
+# Reset
+Color_Off='\033[0m' # Text Reset
+
+# Regular Colors
+Black='\033[0;30m' # Black
+Red='\033[0;31m' # Red
+Green='\033[0;32m' # Green
+Yellow='\033[0;33m' # Yellow
+Blue='\033[0;34m' # Blue
+Purple='\033[0;35m' # Purple
+Cyan='\033[0;36m' # Cyan
+White='\033[0;37m' # White
+
+# Bold
+BBlack='\033[1;30m' # Black
+BRed='\033[1;31m' # Red
+BGreen='\033[1;32m' # Green
+BYellow='\033[1;33m' # Yellow
+BBlue='\033[1;34m' # Blue
+BPurple='\033[1;35m' # Purple
+BCyan='\033[1;36m' # Cyan
+BWhite='\033[1;37m' # White
+
+# Underline
+UBlack='\033[4;30m' # Black
+URed='\033[4;31m' # Red
+UGreen='\033[4;32m' # Green
+UYellow='\033[4;33m' # Yellow
+UBlue='\033[4;34m' # Blue
+UPurple='\033[4;35m' # Purple
+UCyan='\033[4;36m' # Cyan
+UWhite='\033[4;37m' # White
+
+# Background
+On_Black='\033[40m' # Black
+On_Red='\033[41m' # Red
+On_Green='\033[42m' # Green
+On_Yellow='\033[43m' # Yellow
+On_Blue='\033[44m' # Blue
+On_Purple='\033[45m' # Purple
+On_Cyan='\033[46m' # Cyan
+On_White='\033[47m' # White
+
+# High Intensity
+IBlack='\033[0;90m' # Black
+IRed='\033[0;91m' # Red
+IGreen='\033[0;92m' # Green
+IYellow='\033[0;93m' # Yellow
+IBlue='\033[0;94m' # Blue
+IPurple='\033[0;95m' # Purple
+ICyan='\033[0;96m' # Cyan
+IWhite='\033[0;97m' # White
+
+# Bold High Intensity
+BIBlack='\033[1;90m' # Black
+BIRed='\033[1;91m' # Red
+BIGreen='\033[1;92m' # Green
+BIYellow='\033[1;93m' # Yellow
+BIBlue='\033[1;94m' # Blue
+BIPurple='\033[1;95m' # Purple
+BICyan='\033[1;96m' # Cyan
+BIWhite='\033[1;97m' # White
+
+# High Intensity backgrounds
+On_IBlack='\033[0;100m' # Black
+On_IRed='\033[0;101m' # Red
+On_IGreen='\033[0;102m' # Green
+On_IYellow='\033[0;103m' # Yellow
+On_IBlue='\033[0;104m' # Blue
+On_IPurple='\033[0;105m' # Purple
+On_ICyan='\033[0;106m' # Cyan
+On_IWhite='\033[0;107m' # White
+
+# Check requirements
+if ! which echo > /dev/null
+then
+ exit -1
+fi
+
+required_programs=(find grep cut sed sha1sum)
+
+for program in $required_programs
+do
+ if ! which $program > /dev/null
+ then
+ echo -e "${Red}[error] Requires '$program' command to be installed${Color_Off}"
+ exit -1
+ fi
+done
+
+# Actual file processing
+for htmlfile in $(find -type f -name \*.html -not -path "./node_modules/*")
+do
+ echo -e "${BIBlue}[info] Processing '${htmlfile}' for cache busting...${Color_Off}"
+
+ LIST=$(find -type f -regex '.*\.css\|.*\.js' -not -path "./node_modules/*" | sed 's/\.\///g')
+
+ if [ "$1" = "gitadd" ]
+ then
+ LIST=$(git status -s | grep -oE "[A-Z] .+" | cut -d" " -f3)
+ fi
+
+ for resourcefile in $LIST
+ do
+ # Check if resource is used in html file
+ resourceusage=$(grep -i "$resourcefile" "$htmlfile")
+ if [ $? -eq 0 ]
+ then
+ # This is just for cache busting...
+ # If 7 first characters of SHA1 is okay for git, it should be more than enough for us
+ hash="$(sha1sum $resourcefile | cut -d' ' -f1 | head -c 7)"
+
+ # Check if resource hash is already correct
+ if ! echo "$resourceusage" | grep -i "=$hash\"" > /dev/null
+ then
+ escaped=$(echo $resourcefile | sed 's/\//\\\//g' | sed 's/\./\\./g')
+ sed -Ei "s/${escaped}(\?v=[a-z0-9]+)?/${escaped}?v=${hash}/g" "$htmlfile"
+
+ echo -e "${BIBlue}[info]${Color_Off} Updated resource ${resourcefile} to hash ${hash}"
+ fi
+ fi
+ done
+done
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/.gitattributes b/openOutpaint-webUI-extension/app/.gitattributes
new file mode 100644
index 0000000000000000000000000000000000000000..07764a78d9844210a15958387933a1fa526c43a5
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.gitattributes
@@ -0,0 +1 @@
+* text eol=lf
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/.githooks/linux/pre-commit b/openOutpaint-webUI-extension/app/.githooks/linux/pre-commit
new file mode 100755
index 0000000000000000000000000000000000000000..3bfde07c0f01cacd125aa8d91e98eded4159bdef
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.githooks/linux/pre-commit
@@ -0,0 +1,9 @@
+#!/bin/sh
+#
+# Script to perform some basic operations to the code before committing.
+
+# Adds file hashes to html script imports for cache busting purposes
+sh .devtools/updatehashes.sh gitadd
+
+# Adds file to current commit
+git add "**.html"
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/.githooks/windows/pre-commit b/openOutpaint-webUI-extension/app/.githooks/windows/pre-commit
new file mode 100755
index 0000000000000000000000000000000000000000..6b35b7981c0a1957528339bd1cce7c3111db18ad
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.githooks/windows/pre-commit
@@ -0,0 +1,9 @@
+#!/bin/sh
+#
+# Script to perform some basic operations to the code before committing.
+
+# Adds file hashes to html script imports for cache busting purposes
+powershell .devtools/updatehashes.ps1 gitadd
+
+# Adds file to current commit
+git add "**.html"
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/.github/ISSUE_TEMPLATE/bug_report.yml b/openOutpaint-webUI-extension/app/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000000000000000000000000000000000000..70e54f8838aa72743eaeb3fcb80e47dd4d236f36
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,91 @@
+name: Bug Report
+description: You think somethings is broken in the UI
+title: "[Bug]: "
+labels: ["bug"]
+assignees: zero01101, seijihariki
+
+body:
+ - type: markdown
+ attributes:
+ value: |
+ *Please complete this form with as much detailed information as possible.*
+ - type: textarea
+ id: what-did
+ attributes:
+ label: What happened?
+ description: What happened that you weren't expecting, or what happened incorrectly?
+ validations:
+ required: true
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to reproduce the problem
+ description: Please provide us with precise step-by-step information on how to reproduce the issue
+ value: |
+ 1. Go to ....
+ 2. Press ....
+ 3. ... [etc]
+ validations:
+ required: true
+ - type: textarea
+ id: what-should
+ attributes:
+ label: What should have happened?
+ description: Describe what you believe should have ocurred instead of what actually happened.
+ validations:
+ required: true
+ - type: input
+ id: commit
+ attributes:
+ label: Commit where the problem happens
+ description: Which commit are you running? (i.e. https://github.com/zero01101/openOutpaint/commit/bf21c19ae352800d9e1b37bb490e817b6848e533, bf21c19)
+ validations:
+ required: true
+ - type: dropdown
+ id: platforms
+ attributes:
+ label: What platforms do you use to access openOutpaint?
+ multiple: true
+ options:
+ - Windows
+ - Linux
+ - MacOS
+ - iOS
+ - Android
+ - Other/Cloud
+ validations:
+ required: true
+ - type: dropdown
+ id: browsers
+ attributes:
+ label: What browsers do you use to access the UI ?
+ multiple: true
+ options:
+ - Mozilla Firefox
+ - Google Chrome
+ - Brave
+ - Apple Safari
+ - Microsoft Edge
+ - Opera
+ - Other (please list in additional information)
+ validations:
+ required: true
+ - type: textarea
+ id: browser-extensions
+ attributes:
+ label: Browser Extensions/Addons
+ description: Please list all browser extensions/addons you have running. Some have been known to cause issues with openOutpaint.
+ validations:
+ required: true
+ - type: textarea
+ id: webui-commandline
+ attributes:
+ label: AUTOMATIC1111 webUI Commandline Arguments
+ description: Please list all used commandline arguments passed to A1111 webUI (i.e. `--api`).
+ validations:
+ required: true
+ - type: textarea
+ id: misc
+ attributes:
+ label: Additional information
+ description: Please provide us with any relevant additional information, context, screenshots, etc.
diff --git a/openOutpaint-webUI-extension/app/.github/ISSUE_TEMPLATE/feature_request.yml b/openOutpaint-webUI-extension/app/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000000000000000000000000000000000000..60157dc5ed6236799abc6babfb97326594a59260
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,48 @@
+name: Feature request
+description: Suggest an idea for this project
+title: "[Feature Request]: "
+labels: ["enhancement"]
+assignees: zero01101, seijihariki
+
+body:
+ - type: markdown
+ attributes:
+ value: |
+ *Please complete this form with as much detailed information as possible.*
+ - type: textarea
+ id: related
+ attributes:
+ label: Is your feature request related to a problem? Please describe.
+ description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+ validations:
+ required: true
+ - type: textarea
+ id: feature
+ attributes:
+ label: Describe the solution you'd like
+ description: A clear and concise description of what you want to happen, preferably with example use-case scenario.
+ validations:
+ required: true
+ - type: textarea
+ id: workflow
+ attributes:
+ label: Proposed workflow
+ description: Please provide us with step by step information on how you'd like the feature to be accessed and used
+ value: |
+ 1. Go to ....
+ 2. Press ....
+ 3. ...
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Describe alternatives you've considered
+ description: A clear and concise description of any alternative solutions or features you've considered.
+ validations:
+ required: true
+ - type: textarea
+ id: misc
+ attributes:
+ label: Additional context
+ description: Add any other context or screenshots about the feature request here.
diff --git a/openOutpaint-webUI-extension/app/.github/workflows/autoformat.yml b/openOutpaint-webUI-extension/app/.github/workflows/autoformat.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7a95e97b222e67deeed3d3f11968e75f2f697e0a
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.github/workflows/autoformat.yml
@@ -0,0 +1,22 @@
+name: Prettier Autoformatting
+on:
+ push:
+ branches:
+ - "main"
+ - "testing"
+ pull_request:
+ branches: [main, testing]
+ types: [opened, synchronize, closed]
+
+jobs:
+ prettier:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+ with:
+ ref: ${{ github.event.pull_request.head.sha }}
+ - name: Prettify
+ uses: creyD/prettier_action@v4.3
+ with:
+ prettier_options: --write **/*.{js,html,css,md}
diff --git a/openOutpaint-webUI-extension/app/.github/workflows/cachebusting.yml b/openOutpaint-webUI-extension/app/.github/workflows/cachebusting.yml
new file mode 100644
index 0000000000000000000000000000000000000000..68270f26f61ea37de5d0fecc45734165ce189de9
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.github/workflows/cachebusting.yml
@@ -0,0 +1,26 @@
+name: Cache Busting
+on:
+ push:
+ branches:
+ - "main"
+ - "testing"
+ pull_request:
+ branches: [main, testing]
+ types: [opened, synchronize, closed]
+
+jobs:
+ update_hashes:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v3
+ with:
+ ref: ${{ github.event.pull_request.head.ref }}
+ - name: Update hashes in html files
+ run: bash .devtools/updatehashes.sh
+ - name: Commit
+ uses: EndBug/add-and-commit@v9.1.1
+ with:
+ committer_name: Github Actions
+ committer_email: actions@github.com
+ message: "Fixed resource hashes"
diff --git a/openOutpaint-webUI-extension/app/.gitignore b/openOutpaint-webUI-extension/app/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..621ea6d82b1c432a4d7302b12a04290c7f6aaf3d
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.gitignore
@@ -0,0 +1,12 @@
+.vscode/*
+
+# Key for embedding
+key.json
+
+# NPM things
+package.json
+package-lock.json
+node_modules/
+
+# Yarn things
+yarn.lock
diff --git a/openOutpaint-webUI-extension/app/.prettierrc.json b/openOutpaint-webUI-extension/app/.prettierrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..328b6f3c69a31b84d4b8e8b948ce4ccdf4ed86b3
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/.prettierrc.json
@@ -0,0 +1,19 @@
+{
+ "arrowParens": "always",
+ "bracketSameLine": true,
+ "bracketSpacing": false,
+ "embeddedLanguageFormatting": "auto",
+ "htmlWhitespaceSensitivity": "ignore",
+ "insertPragma": false,
+ "jsxSingleQuote": false,
+ "printWidth": 80,
+ "proseWrap": "preserve",
+ "quoteProps": "as-needed",
+ "requirePragma": false,
+ "semi": true,
+ "singleQuote": false,
+ "tabWidth": 2,
+ "trailingComma": "es5",
+ "useTabs": true,
+ "vueIndentScriptAndStyle": false
+}
diff --git a/openOutpaint-webUI-extension/app/CONTRIBUTING.md b/openOutpaint-webUI-extension/app/CONTRIBUTING.md
new file mode 100644
index 0000000000000000000000000000000000000000..e25a52f9621c2ea542035d326f9fea7c23bae663
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/CONTRIBUTING.md
@@ -0,0 +1,63 @@
+# Contributing to openOutpaint
+
+We love your input! We want to make contributing to this project as easy and transparent as possible, whether it's:
+
+- Reporting a bug
+- Discussing the current state of the code
+- Submitting a fix
+- Proposing new features
+- Becoming a maintainer
+
+## We Develop with Github
+
+We use github to host code, to track issues and feature requests, as well as accept pull requests.
+
+## We Use [Github Flow](https://guides.github.com/introduction/flow/index.html), So All Code Changes Happen Through Pull Requests
+
+Pull requests are the best way to propose changes to the codebase (we use [Github Flow](https://guides.github.com/introduction/flow/index.html)). We actively welcome your pull requests:
+
+1. Fork the repo and create your branch from `main` or `testing`.
+2. Setup [commit hooks](https://github.com/zero01101/openOutpaint/tree/main/.devtools) for automatic calculation of cache busting hashes for resources.
+3. Please add comments when reasonable, and when possible, use [JSDoc](https://jsdoc.app/) for documenting types. Lack of this will not prevent your pull being merged, but it would be nice to have.
+4. If you've added code that should be tested please pull into `testing`. For documentation and smaller fixes, a pull request directly to `main` should be okay, unless it pertains to changes only present in `testing`.
+5. Create a pull request with a short description of what you did. Thanks for your contribution!
+
+## Any contributions you make will be under the MIT Software License
+
+In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern.
+
+## Report bugs using Github's [issues](https://github.com/zero01101/openOutpaint/issues)
+
+We use GitHub issues to track public bugs. Report a bug by [opening a new issue](https://github.com/zero01101/openOutpaint/issues); it's that easy!
+
+## Write bug reports with detail, background, and sample code
+
+If possible, bug reports should have the most detail it is reasonable to have for the bug in question. If you are more versed in javascript, pointing out the issue in code, or even creating a pull request is also appreciated!
+
+**Great Bug Reports** tend to have:
+
+- A quick summary and/or background
+- Steps to reproduce
+ - Be specific!
+ - Give sample code or screenshots if you can!
+- What you expected would happen
+- What actually happens
+- Notes (possibly including why you think this might be happening, or stuff you tried that didn't work)
+
+We have some issue templates that are, admittedly, basically github's default templates. You can and should use that as a guide. Sometimes some fields may not be applicable to your particular report. In this case, things such as _alternative solutions_ don't need to be included.
+
+People _love_ thorough bug reports. I'm not even kidding.
+
+## Use a Consistent Coding Style
+
+For styling, we are currently using prettier for linting. And that's basically it. We are currently using tabs and some other defaults defined in the [.prettierrc.json](https://github.com/zero01101/openOutpaint/blob/main/.prettierrc.json) file. We don't use npm on our project, so you would have to install prettier and prettier-eslint locally. We have a handy [lint.sh](https://github.com/zero01101/openOutpaint/blob/main/lint.sh) script you can run, but it is recommended to use an IDE with prettier support for more practical use.
+
+Any suggestions regarding change of styles or style guides (Airbnb, Google, or whatnot) are welcome, as the current file is quite simplistic.
+
+## License
+
+By contributing, you agree that your contributions will be licensed under its MIT License.
+
+## References
+
+This document was adapted from the open-source contribution guidelines for [Facebook's Draft](https://github.com/facebook/draft-js/blob/a9316a723f9e918afde44dea68b5f9f39b7d9b00/CONTRIBUTING.md) and based on [this template from briandk](https://gist.github.com/briandk/3d2e8b3ec8daf5a27a62).
diff --git a/openOutpaint-webUI-extension/app/LICENSE b/openOutpaint-webUI-extension/app/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..2228febc5439cf0dfb8165c06a1c4cb1e671aa99
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 tim h
+
+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.
diff --git a/openOutpaint-webUI-extension/app/README.md b/openOutpaint-webUI-extension/app/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..879c0830d4d7149df0374d0d32b116d3405e8d78
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/README.md
@@ -0,0 +1,92 @@
+# hello there 🐠
+
+[openOutpaint creating some undersea... well, stuff](https://user-images.githubusercontent.com/1649724/205455599-7817812e-5b50-4c96-807e-268b40fa2fd7.mp4)
+
+_silly demo example current as of [9b174d6](https://github.com/zero01101/openOutpaint/commit/9b174d66c9b9d83ce8657128c97f917b473b13a9) / v0.0.8 / 2022-12-03_ //TODO UPDATE SRSLY
+
+this is a completely vanilla javascript and html canvas outpainting convenience doodad built for the API optionally exposed by [AUTOMATIC1111's stable diffusion webUI](https://github.com/AUTOMATIC1111/stable-diffusion-webui), operating similarly to a few others that are probably more well-known. this simply offers an alternative for my following vain desires:
+
+- avoiding the overhead of an additional virtual python environment or impacting a pre-existing one
+- operates against the API exposed by A1111's webUI
+- no external dependencies, extremely boring vanilla
+- no external connectivity, self-hosted and offline
+- unobfuscated (cough cough)
+- i am terrible at javascript and should probably correct that
+- i have never used html canvas for anything before and should try it out
+
+## features
+
+- SDXL "support"! (please check outpaint/inpaint fill types in the context menus and fiddle with denoising a LOT for img2img, it's touchy)
+- [now available as an extension for webUI!](https://github.com/zero01101/openOutpaint-webUI-extension) you can find it under the default "available" section in the webUI _extensions_ tab
+ - **_NOTE: extension still requires `--api` flag in webui-user launch script_**
+- intuitive, convenient outpainting - that's like the whole point right
+- queueable, cancelable dreams - just start a'clickin' all over the place
+- arbitrary dream reticle size - draw the rectangle of your dreams
+- an [effectively infinite](https://github.com/zero01101/openOutpaint/pull/108), resizable, scalable canvas for you to paint all over
+ - **_NOTE: v0.0.10 introduces a new "camera control" modifier key - hold [`CTRL`] and use the scrollwheel to zoom (scroll the wheel or use the two-finger vertical gesture on, uh, modern touchpads) and pan (hold the scrollwheel button, or if you don't have one, left-click button) around the canvas_**
+- extremely limited, janky support for a shockingly restrictive list of A1111 extensions including controlnet inpainting for legitimately magic promptless inpainting and outpainting ([a](https://github.com/Mikubill/sd-webui-controlnet/discussions/1464), [b](https://github.com/Mikubill/sd-webui-controlnet/discussions/1143), [c](https://github.com/Mikubill/sd-webui-controlnet/discussions/1597)) and in-line reference preprocessors for keeping existing style while replacing things (reference requires at least 2 controlnet units enabled in A1111 settings), as well as a very very basic dynamic-prompts-on-or-off toggle
+ - **_NOTE: this is_ JANKY, _pull requests greatly welcomed lol_**
+- a very nicely functional and familiar layer system
+- save, load, import, and export workspaces - includes all your layers, history, canvas size, you name it!
+- inpainting/touchup mask brush
+- webUI script support (but you gotta [_find it_](https://github.com/zero01101/openOutpaint/wiki/Manual))
+- prompt history panel
+- optional (visibly) inverted mask mode - red masks get mutated, blue masks stay the same, but you can't take both pills at once
+- inpainting color brush to bring out your inner vincent van bob ross
+- dedicated img2img tool with optional border masking for enhanced output coherence with existing subject matter
+- marquee select tool to select regions and arbitrarily scale, rotate, create stamps, move chunks, peek at lower layers, do all sorts of damage
+- optionally decoupled cursor size and output resolution
+- interrogate tool
+- floating control panel to easily change models/samplers/steps/prompts/CFG/etc options for each dream summoned from the latent void _(NOTE: model switching requires A1111 webUI to be on commit [5a6387e](https://github.com/AUTOMATIC1111/stable-diffusion-webui/commit/5a6387e189dc365c47a7979b9040d5b6fdd7ba43) or more recent)_
+- floating toolbox with handy keyboard shortcuts
+- optional grid snapping for precision
+- optional hi-res fix for blank/txt2img dreams
+ - **_NOTE: as of v0.0.12.5/webUI commit [ef27a18](https://github.com/AUTOMATIC1111/stable-diffusion-webui/commit/ef27a18b6b7cb1a8eebdc9b2e88d25baf2c2414d), HRfix has been COMPLETELY reworked and no longer works remotely the same, thus openOutpaint's implementation is no longer compatible with versions of A1111 predating that. You will be alerted to the outdated webUI and the HRfix option will become limited to simply using [reticle dimensions / 2] in this event. Please see the [manual entry](https://github.com/zero01101/openOutpaint/wiki/Manual#hrfix) regarding HRfix and its available options._**
+- optional overmasking for potentially better seams between outpaints - set overmask px value to 0 to disable the feature
+- import arbitrary images and rotate/scale/stamp on the canvas whenever, wherever you'd like
+- upscaler support for final output images
+- saves your preferences/imported images to browser localstorage for maximum convenience
+- reset to defaults button to unsave your preferences if things go squirrely
+- floating navigable undo/redo palette with ctrl+z/y keyboard shortcuts for additional maximum convenience and desquirreliness
+- optional generate-ahead function to keep crankin' out the dreams while you look through the ones that already exist
+- _all this and much more for the low, low price of simply already owning an expensive GPU!_
+
+## operation
+
+**_NOTE: [PLEASE SEE DOCUMENTATION REGARDING NEW HRfix FEATURES](https://github.com/zero01101/openOutpaint/wiki/Manual#hrfix) IMPLEMENTED AS OF webUI COMMIT [ef27a18](https://github.com/AUTOMATIC1111/stable-diffusion-webui/commit/ef27a18b6b7cb1a8eebdc9b2e88d25baf2c2414d)_**
+
+### prerequisities
+
+you'll obviously need A1111's webUI installed before you can use this, thus you're presumed to have an operational python install up and running to boot.
+
+A1111 webUI must be launched with the `--api` flag enabled, and the `--cors-allow-origins=` flag set with the host where openOutpaint will be running.
+
+**_NOTE: the commandline flag `--gradio-debug` disables custom API routes and completely breaks openOutpaint. please remove it from your COMMANDLINE_ARGS before running openOutpaint._**
+
+### surprising incompatibilities
+
+**_COLAB USERS: you may experience issues installing openOutpaint (and other webUI extensions) - there is a workaround that has been discovered and tested against [TheLastBen's fast-stable-diffusion](https://github.com/TheLastBen/fast-stable-diffusion). Please see [this discussion](https://github.com/TheLastBen/fast-stable-diffusion/discussions/1161) containing the workaround, which requires adding a command into the final cell of the colab, as well as setting `Enable_API` to `True`._**
+
+If anything goes wrong with openOutpaint, try running it on another browser and disable all extensions and try again. If a new incompatible extension is found, please open an issue so we can notify other users of extension incompatibilities.
+
+- [microsoft editor extension for chrome/edge seems to disable the overmask slider](https://github.com/zero01101/openOutpaint/discussions/88#discussioncomment-4498341)
+- ~~[duckduckgo privacy extension for firefox breaks outpainting, resulting in pure black output](https://github.com/zero01101/openOutpaint-webUI-extension/issues/3#issuecomment-1367694000) - add an exception for your openOutpaint host (likely localhost or 127.0.0.1)~~ should be fixed as of [b128943](https://github.com/zero01101/openOutpaint/commit/b128943f0c94970600fdc1c98bfec22de619866f)
+- ~~[same for dark reader](https://github.com/zero01101/openOutpaint-webUI-extension/issues/3#issuecomment-1367838766)~~ same for dark reader
+
+### quickstart speedrun
+
+1. edit your `cors-allow-origins` to include https://zero01101.github.io and run webUI
+2. go to https://zero01101.github.io/openOutpaint/ and fill in the host value with your webUI API address
+3. click things and do stuff
+
+### step-by-step actually useful instructions
+
+please see the [quickstart wiki article](https://github.com/zero01101/openOutpaint/wiki/SBS-Guided-Example) and comprehensive [manual](https://github.com/zero01101/openOutpaint/wiki/Manual).
+
+## pull requests/bug reports
+
+please do! see [contributing](https://github.com/zero01101/openOutpaint/blob/main/CONTRIBUTING.md) for details!
+
+## warranty
+
+[lmao](https://github.com/moyix/fauxpilot#support-and-warranty)
diff --git a/openOutpaint-webUI-extension/app/css/colors.css b/openOutpaint-webUI-extension/app/css/colors.css
new file mode 100644
index 0000000000000000000000000000000000000000..3034b8619c566f818ebf7905d4ac9ba3458d64fc
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/colors.css
@@ -0,0 +1,10 @@
+:root {
+ --c-primary: #2c3333;
+ --c-disabled: rgb(81, 81, 81);
+ --c-hover: hsl(180, 7%, 30%);
+ --c-active: hsl(180, 7%, 25%);
+ --c-primary-accent: hsl(180, 7%, 40%);
+ --c-secondary: #395b64;
+ --c-accent: #a5c9ca;
+ --c-text: #e7f6f2;
+}
diff --git a/openOutpaint-webUI-extension/app/css/fonts.css b/openOutpaint-webUI-extension/app/css/fonts.css
new file mode 100644
index 0000000000000000000000000000000000000000..219997c9dd11a7e0d751eeee6c468a04efdf5774
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/fonts.css
@@ -0,0 +1,4 @@
+@font-face {
+ font-family: "Open Sans", sans-serif;
+ src: url("../res/fonts/OpenSans.ttf") format("truetype");
+}
diff --git a/openOutpaint-webUI-extension/app/css/icons.css b/openOutpaint-webUI-extension/app/css/icons.css
new file mode 100644
index 0000000000000000000000000000000000000000..08e7c00350f986526c37d6d9b1012922ad8857fa
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/icons.css
@@ -0,0 +1,198 @@
+.ui.inline-icon {
+ position: relative;
+
+ display: flex;
+}
+
+.ui.inline-icon::after {
+ content: "";
+ display: block;
+
+ position: absolute;
+
+ box-sizing: border-box;
+
+ margin: auto;
+ top: 15%;
+ bottom: 15%;
+
+ mask-size: contain;
+ -webkit-mask-size: contain;
+ mask-repeat: no-repeat;
+ -webkit-mask-repeat: no-repeat;
+
+ max-height: 70%;
+ aspect-ratio: 1;
+
+ left: 0;
+ right: 0;
+
+ background-color: var(--c-text);
+}
+
+.ui.inline-icon.icon-eye-off::after,
+.ui.icon > .icon-eye-off {
+ -webkit-mask-image: url("../res/icons/eye-off.svg");
+ mask-image: url("../res/icons/eye-off.svg");
+}
+
+.ui.icon > .icon-eye {
+ -webkit-mask-image: url("../res/icons/eye.svg");
+ mask-image: url("../res/icons/eye.svg");
+}
+
+.ui.icon > .icon-file-plus {
+ -webkit-mask-image: url("../res/icons/file-plus.svg");
+ mask-image: url("../res/icons/file-plus.svg");
+}
+
+.ui.inline-icon.icon-flip-horizontal::after,
+.ui.icon > .icon-flip-horizontal {
+ -webkit-mask-image: url("../res/icons/flip-horizontal.svg");
+ mask-image: url("../res/icons/flip-horizontal.svg");
+}
+
+.ui.inline-icon.icon-flip-vertical::after,
+.ui.icon > .icon-flip-vertical {
+ -webkit-mask-image: url("../res/icons/flip-vertical.svg");
+ mask-image: url("../res/icons/flip-vertical.svg");
+}
+
+.ui.icon > .icon-file-x {
+ -webkit-mask-image: url("../res/icons/file-x.svg");
+ mask-image: url("../res/icons/file-x.svg");
+}
+
+.ui.icon > .icon-chevron-down {
+ -webkit-mask-image: url("../res/icons/chevron-down.svg");
+ mask-image: url("../res/icons/chevron-down.svg");
+}
+
+.ui.icon > .icon-chevron-up {
+ -webkit-mask-image: url("../res/icons/chevron-up.svg");
+ mask-image: url("../res/icons/chevron-up.svg");
+}
+.ui.icon > .icon-chevron-first {
+ -webkit-mask-image: url("../res/icons/chevron-first.svg");
+ mask-image: url("../res/icons/chevron-first.svg");
+}
+
+.ui.icon > .icon-chevron-flat-down {
+ -webkit-mask-image: url("../res/icons/chevron-first.svg");
+ mask-image: url("../res/icons/chevron-first.svg");
+ transform: rotate(-90deg);
+}
+
+.ui.icon > .icon-scroll {
+ -webkit-mask-image: url("../res/icons/scroll.svg");
+ mask-image: url("../res/icons/scroll.svg");
+}
+
+.ui.icon > .icon-settings {
+ -webkit-mask-image: url("../res/icons/settings.svg");
+ mask-image: url("../res/icons/settings.svg");
+}
+
+.ui.icon > .icon-unzoom {
+ -webkit-mask-image: url("../res/icons/scaling.svg");
+ mask-image: url("../res/icons/scaling.svg");
+}
+
+.ui.inline-icon.icon-grid::after,
+.ui.icon > .icon-grid {
+ -webkit-mask-image: url("../res/icons/grid.svg");
+ mask-image: url("../res/icons/grid.svg");
+}
+
+.ui.inline-icon.icon-venetian-mask::after,
+.ui.icon > .icon-venetian-mask {
+ -webkit-mask-image: url("../res/icons/venetian-mask.svg");
+ mask-image: url("../res/icons/venetian-mask.svg");
+}
+
+.ui.inline-icon.icon-brush::after,
+.ui.icon > .icon-brush {
+ -webkit-mask-image: url("../res/icons/brush.svg");
+ mask-image: url("../res/icons/brush.svg");
+}
+
+.ui.inline-icon.icon-paintbrush::after,
+.ui.icon > .icon-paintbrush {
+ -webkit-mask-image: url("../res/icons/paintbrush.svg");
+ mask-image: url("../res/icons/paintbrush.svg");
+}
+
+.ui.inline-icon.icon-slice::after,
+.ui.icon > .icon-slice {
+ -webkit-mask-image: url("../res/icons/slice.svg");
+ mask-image: url("../res/icons/slice.svg");
+}
+
+.ui.inline-icon.icon-joystick::after,
+.ui.icon > .icon-joystick {
+ -webkit-mask-image: url("../res/icons/joystick.svg");
+ mask-image: url("../res/icons/joystick.svg");
+}
+
+.ui.inline-icon.icon-save::after,
+.ui.icon > .icon-save {
+ -webkit-mask-image: url("../res/icons/save.svg");
+ mask-image: url("../res/icons/save.svg");
+}
+
+.ui.inline-icon.icon-pencil::after,
+.ui.icon > .icon-pencil {
+ -webkit-mask-image: url("../res/icons/pencil.svg");
+ mask-image: url("../res/icons/pencil.svg");
+}
+
+.ui.inline-icon.icon-download::after,
+.ui.icon > .icon-download {
+ -webkit-mask-image: url("../res/icons/download.svg");
+ mask-image: url("../res/icons/download.svg");
+}
+.ui.inline-icon.icon-upload::after,
+.ui.icon > .icon-upload {
+ -webkit-mask-image: url("../res/icons/upload.svg");
+ mask-image: url("../res/icons/upload.svg");
+}
+.ui.inline-icon.icon-more-horizontal::after,
+.ui.icon > .icon-more-horizontal {
+ -webkit-mask-image: url("../res/icons/more-horizontal.svg");
+ mask-image: url("../res/icons/more-horizontal.svg");
+}
+.ui.inline-icon.icon-trash::after,
+.ui.icon > .icon-trash {
+ -webkit-mask-image: url("../res/icons/trash.svg");
+ mask-image: url("../res/icons/trash.svg");
+}
+
+.ui.inline-icon.icon-expand::after,
+.ui.icon > .icon-expand {
+ -webkit-mask-image: url("../res/icons/expand.svg");
+ mask-image: url("../res/icons/expand.svg");
+}
+
+.ui.inline-icon.icon-pin::after,
+.ui.icon > .icon-pin {
+ -webkit-mask-image: url("../res/icons/pin.svg");
+ mask-image: url("../res/icons/pin.svg");
+}
+
+.ui.inline-icon.icon-box-select::after,
+.ui.icon > .icon-box-select {
+ -webkit-mask-image: url("../res/icons/box-select.svg");
+ mask-image: url("../res/icons/box-select.svg");
+}
+
+.ui.inline-icon.icon-maximize::after,
+.ui.icon > .icon-maximize {
+ -webkit-mask-image: url("../res/icons/maximize.svg");
+ mask-image: url("../res/icons/maximize.svg");
+}
+
+.ui.inline-icon.icon-clipboard-list::after,
+.ui.icon > .icon-clipboard-list {
+ -webkit-mask-image: url("../res/icons/clipboard-list.svg");
+ mask-image: url("../res/icons/clipboard-list.svg");
+}
diff --git a/openOutpaint-webUI-extension/app/css/index.css b/openOutpaint-webUI-extension/app/css/index.css
new file mode 100644
index 0000000000000000000000000000000000000000..9f3dafcf3d123b463da090ce18ef55e6e95fa7a7
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/index.css
@@ -0,0 +1,678 @@
+* {
+ font-size: 100%;
+ font-family: Arial, Helvetica, sans-serif;
+ user-select: none;
+}
+
+input,
+textarea {
+ user-select: auto;
+}
+
+/* Body is stuck with no scroll */
+body {
+ width: 100%;
+ height: 100%;
+
+ margin: 0px;
+ padding: 0px;
+
+ overflow: clip;
+}
+
+.invisible {
+ display: none !important;
+}
+
+.collapsible {
+ background-color: rgb(0, 0, 0);
+ color: rgb(255, 255, 255);
+ border-radius: 5px;
+ cursor: pointer;
+ width: 100%;
+ border: none;
+ text-align: center;
+ outline: none;
+ padding: 0px;
+}
+
+.collapsible {
+ background-color: var(--c-primary);
+
+ margin-bottom: 2px;
+ margin-top: 5px;
+
+ transition-duration: 50ms;
+}
+
+.collapsible::before {
+ content: "";
+ display: block;
+
+ position: absolute;
+
+ width: 21px;
+ height: 21px;
+
+ background-color: var(--c-text);
+ mask-image: url("../res/icons/chevron-up.svg");
+ -webkit-mask-image: url("../res/icons/chevron-up.svg");
+ mask-size: contain;
+ -webkit-mask-size: contain;
+ rotate: 90deg;
+}
+
+.collapsible.active::before {
+ rotate: 180deg;
+}
+
+.display-none {
+ display: none;
+}
+
+.collapsible:hover {
+ background-color: var(--c-hover);
+}
+
+.collapsible:active {
+ filter: brightness(110%);
+}
+
+.content {
+ max-height: 0;
+ overflow-y: clip;
+ overflow-x: visible;
+ transition:
+ max-height 0.2s ease-out,
+ height 0s ease-out;
+}
+
+.menu-container {
+ background-color: rgba(255, 255, 255, 0.5);
+ padding-left: 10px;
+ padding-right: 10px;
+ padding-top: 5px;
+ padding-bottom: 5px;
+
+ color: black;
+ border: solid;
+ border-top: none;
+ border-color: black;
+ font-size: medium;
+ text-align: left;
+ max-height: fit-content;
+ overflow: visible;
+ cursor: auto;
+}
+
+#page-overlay-wrapper {
+ position: fixed;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+
+ background-color: #fff6;
+ backdrop-filter: blur(5px);
+
+ transition-duration: 50ms;
+
+ z-index: 1000;
+}
+
+.page-overlay-window {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+
+ border-radius: 10px;
+
+ min-width: 400px;
+ width: 400px;
+ min-height: 260px;
+ height: 260px;
+
+ color: var(--c-text);
+
+ overflow: hidden;
+
+ position: absolute;
+
+ margin: auto;
+
+ background-color: var(--c-primary);
+}
+
+.page-overlay-window .close {
+ position: absolute;
+
+ cursor: pointer;
+
+ top: 0;
+ right: 0;
+
+ margin: 5px;
+
+ width: 25px;
+ height: 25px;
+
+ -webkit-mask-image: url("../res/icons/x.svg");
+ mask-image: url("../res/icons/x.svg");
+
+ background-color: var(--c-text);
+}
+
+.page-overlay-window .close:hover {
+ transform: scale(1.1);
+}
+
+.page-overlay-window .title {
+ padding: 10px;
+ padding-top: 7px;
+
+ font-size: large;
+ font-weight: bold;
+
+ margin: auto;
+
+ background-color: var(--c-primary);
+}
+
+#page-overlay {
+ border: 0;
+
+ min-height: 200px;
+ max-height: 600px;
+
+ width: 100%;
+ height: 100%;
+}
+
+/* Mask colors for mask inversion */
+/* Filters are some magic acquired at https://codepen.io/sosuke/pen/Pjoqqp */
+.mask-canvas {
+ opacity: 0%;
+}
+
+.mask-canvas.display {
+ opacity: 40%;
+ filter: invert(100%);
+}
+
+.mask-canvas.display.opaque {
+ opacity: 100%;
+}
+
+.mask-canvas.display.clear {
+ filter: invert(71%) sepia(46%) saturate(6615%) hue-rotate(321deg)
+ brightness(106%) contrast(100%);
+}
+
+.mask-canvas.display.hold {
+ filter: invert(41%) sepia(16%) saturate(5181%) hue-rotate(218deg)
+ brightness(103%) contrast(108%);
+}
+
+.wideSelect {
+ width: 100%;
+ text-overflow: ellipsis;
+}
+
+/* Host input */
+.host-field-wrapper {
+ position: relative;
+ display: flex;
+
+ align-items: stretch;
+ justify-content: space-between;
+
+ width: 100%;
+}
+
+.host-field-wrapper > .host-field {
+ display: flex;
+ width: calc(100% - 15px);
+}
+
+.host-field-wrapper > .host-field > .label {
+ padding-left: 5px;
+ padding-right: 5px;
+
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+
+ background-color: var(--c-primary);
+ color: var(--c-text);
+}
+
+.host-field-wrapper input {
+ display: block;
+ min-width: 0;
+
+ border: 0;
+}
+
+.host-field-wrapper .connection-status {
+ width: 15px;
+
+ position: absolute;
+ left: calc(100% - 15px);
+
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+
+ box-sizing: inherit;
+
+ cursor: pointer;
+
+ transition-duration: 50ms;
+
+ padding-top: 1px;
+ padding-bottom: 1px;
+
+ overflow: hidden;
+}
+.host-field-wrapper .connection-status:active,
+.host-field-wrapper .connection-status:hover {
+ width: fit-content;
+ padding-left: 5px;
+ padding-right: 6px;
+
+ filter: brightness(110%);
+}
+
+.host-field-wrapper .connection-status:active {
+ filter: brightness(80%);
+}
+
+.host-field-wrapper .connection-status > #connection-status-indicator-text {
+ opacity: 0%;
+ transition-duration: 20ms;
+}
+
+.host-field-wrapper
+ .connection-status:hover
+ > #connection-status-indicator-text {
+ opacity: 100%;
+}
+
+.host-field-wrapper .connection-status.online {
+ background-color: #49dd49;
+ color: #1f3f1f;
+}
+
+.host-field-wrapper .connection-status.offline {
+ background-color: #dd4949;
+ color: #3f1f1f;
+}
+
+.host-field-wrapper .connection-status.webui-issue {
+ background-color: #dddd49;
+ color: #3f3f1f;
+}
+
+.host-field-wrapper .connection-status.before {
+ background-color: #777;
+ color: #1f1f1f;
+}
+
+input#host {
+ box-sizing: border-box;
+}
+
+/* Model Select */
+#models-ac-select option {
+ background-color: #fcc;
+}
+
+#models-ac-select option.inpainting {
+ background-color: #cfc;
+}
+
+/* Settings button */
+.ui.icon.header-button {
+ padding: 0;
+ border: 0;
+
+ cursor: pointer;
+
+ background-color: transparent;
+}
+
+.ui.icon.header-button > *:first-child {
+ background-color: black;
+
+ -webkit-mask-size: contain;
+ mask-size: contain;
+
+ width: 28px;
+ height: 28px;
+ transition-duration: 30ms;
+}
+
+.ui.icon.header-button:hover > *:last-child {
+ transform: scale(1.1);
+}
+
+/* Prompt Fields */
+
+.content.prompt {
+ display: flex;
+ align-items: stretch;
+}
+
+.content.prompt > .inputs {
+ width: 200px;
+}
+
+div.prompt-wrapper {
+ display: flex;
+
+ width: calc(100%);
+
+ overflow: visible;
+}
+
+div.prompt-wrapper > * {
+ flex-shrink: 0;
+}
+
+div.prompt-wrapper textarea {
+ margin: 0;
+
+ border-radius: 0;
+
+ border: none;
+
+ z-index: 1;
+}
+
+div.prompt-wrapper:not(:first-child) textarea {
+ border-top: 1px solid black;
+}
+
+div.prompt-wrapper > textarea {
+ box-sizing: border-box;
+ width: calc(100% - 20px);
+
+ padding: 2px;
+
+ transition-duration: 200ms;
+
+ resize: vertical;
+}
+
+div.prompt-wrapper > textarea:focus {
+ width: 700px;
+}
+
+div.prompt-wrapper > .prompt-indicator {
+ display: flex;
+
+ cursor: help;
+
+ width: 20px;
+}
+
+div.prompt-wrapper:first-child > .prompt-indicator {
+ border-top-left-radius: 5px;
+}
+
+div.prompt-wrapper:last-child > .prompt-indicator {
+ border-bottom-left-radius: 5px;
+}
+
+div.prompt-wrapper > .prompt-indicator.positive {
+ background-color: #484;
+}
+
+div.prompt-wrapper > .prompt-indicator.negative {
+ background-color: #844;
+}
+div.prompt-wrapper > .prompt-indicator.styles {
+ background-color: #448;
+}
+
+div.prompt-wrapper > .prompt-indicator::after {
+ content: "";
+ display: block;
+ margin: auto;
+
+ width: 16px;
+ height: 16px;
+
+ background-color: var(--c-text);
+
+ mask-size: contain;
+ -webkit-mask-size: contain;
+}
+
+div.prompt-wrapper > .prompt-indicator.positive::after {
+ mask-image: url("../res/icons/plus-square.svg");
+ -webkit-mask-image: url("../res/icons/plus-square.svg");
+}
+
+div.prompt-wrapper > .prompt-indicator.negative::after {
+ mask-image: url("../res/icons/minus-square.svg");
+ -webkit-mask-image: url("../res/icons/minus-square.svg");
+}
+
+div.prompt-wrapper > .prompt-indicator.styles::after {
+ mask-image: url("../res/icons/library.svg");
+ -webkit-mask-image: url("../res/icons/library.svg");
+}
+
+.prompt-history-wrapper {
+ position: relative;
+
+ flex-shrink: 0;
+
+ width: 20px;
+}
+
+.prompt-history-container {
+ display: flex;
+
+ position: absolute;
+
+ top: 0;
+ left: 0;
+
+ height: 100%;
+}
+
+#prompt-history {
+ width: 0px;
+ height: 100%;
+
+ transition-duration: 200ms;
+
+ background-color: #1e1e50;
+}
+
+#prompt-history.expanded {
+ width: 300px;
+ overflow-y: auto;
+}
+
+#prompt-history .entry {
+ display: flex;
+ align-items: stretch;
+ justify-content: stretch;
+
+ border: 1px #fff3;
+
+ height: 25px;
+}
+
+#prompt-history.expanded .entry > button {
+ padding: 2px;
+ padding-left: 5px;
+}
+
+#prompt-history .entry > button {
+ flex: 1;
+
+ cursor: pointer;
+
+ margin: 0;
+ border: 0;
+ padding: 0;
+
+ color: var(--c-text);
+
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ transition-duration: 100ms;
+}
+
+#prompt-history .entry:hover > button:not(:hover) {
+ flex-grow: 0;
+ flex-shrink: 1;
+ flex-basis: 20%;
+ width: 20%;
+}
+
+#prompt-history .entry > button.prompt {
+ background-color: #484;
+}
+
+#prompt-history .entry > button.negative {
+ background-color: #844;
+}
+
+#prompt-history .entry > button.styles {
+ background-color: #448;
+}
+
+#prompt-history .entry > button:hover {
+ filter: brightness(115%);
+ backdrop-filter: brightness(115%);
+}
+
+#prompt-history .entry > button:active {
+ filter: brightness(150%);
+ backdrop-filter: brightness(150%);
+}
+
+button.prompt-history-btn {
+ cursor: pointer;
+
+ border-radius: 0;
+
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+
+ background-color: #1e1e50;
+
+ margin: 0;
+ padding: 0;
+ border: 0;
+
+ width: 20px;
+}
+
+button.prompt-history-btn::after {
+ content: "";
+ display: block;
+ margin: auto;
+
+ width: 16px;
+ height: 16px;
+
+ background-color: var(--c-text);
+
+ mask-size: contain;
+ -webkit-mask-size: contain;
+
+ mask-image: url("../res/icons/history.svg");
+ -webkit-mask-image: url("../res/icons/history.svg");
+}
+
+button.prompt-history-btn:hover {
+ filter: brightness(115%);
+}
+
+button.prompt-history-btn:active {
+ filter: brightness(150%);
+}
+
+/* Style Field */
+select > .style-select-option {
+ position: relative;
+
+ cursor: pointer;
+}
+
+select > .style-select-option:hover {
+ background-color: #999;
+}
+
+select > .style-select-option:active {
+ background-color: #aaa;
+}
+
+/* Tool buttons */
+.button-array {
+ display: flex;
+ justify-content: stretch;
+}
+
+.button-array > .button.tool {
+ flex: 1;
+ border-radius: 0;
+}
+
+.button-array > .button.tool:first-child {
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+}
+
+.button-array > .button.tool:last-child {
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+}
+
+.button.tool {
+ background-color: rgb(0, 0, 50);
+ color: rgb(255, 255, 255);
+ cursor: pointer;
+ border: none;
+ text-align: center;
+ outline: none;
+ font-size: 15px;
+ padding: 5px;
+ margin-top: 5px;
+ margin-bottom: 5px;
+}
+
+.button.tool:disabled {
+ background-color: #666 !important;
+ cursor: default;
+}
+
+.button.tool:hover {
+ background-color: rgb(30, 30, 80);
+}
+.button.tool:active,
+.button.tool.active {
+ background-color: rgb(60, 60, 130);
+}
+
+/* Miscellaneous garbage */
+
+.thirdwidth {
+ max-width: 33%;
+}
+
+.refreshbutton {
+ max-width: 50%;
+ max-height: 50%;
+}
diff --git a/openOutpaint-webUI-extension/app/css/layers.css b/openOutpaint-webUI-extension/app/css/layers.css
new file mode 100644
index 0000000000000000000000000000000000000000..434e77e1b54e21934390397928712235aa0b7136
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/layers.css
@@ -0,0 +1,71 @@
+/* Debug floating window */
+#layer-preview .preview-canvas {
+ background-color: white;
+ width: 100%;
+ height: 150px;
+}
+
+#layer-manager .menu-container {
+ height: 200px;
+}
+
+.layer-render-target {
+ position: fixed;
+ background-color: #466;
+
+ margin: 0;
+ padding: 0;
+
+ top: 0;
+ left: 0;
+
+ width: 100%;
+ height: 100%;
+}
+
+.layer-render-target .collection {
+ position: absolute;
+ transform-origin: 0px 0px;
+}
+
+.layer-render-target .collection > .collection-input-overlay {
+ position: absolute;
+
+ top: 0;
+ left: 0;
+
+ z-index: 10;
+}
+
+.layer-render-target canvas {
+ position: absolute;
+
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+}
+
+.overlay-canvas {
+ position: fixed;
+
+ top: 0;
+ left: 0;
+
+ width: 100%;
+ height: 100%;
+
+ pointer-events: none;
+
+ z-index: 15;
+}
+
+#layer-render.pixelated canvas {
+ image-rendering: pixelated;
+ image-rendering: crisp-edges;
+}
+
+canvas.pixelated {
+ image-rendering: pixelated;
+ image-rendering: crisp-edges;
+}
diff --git a/openOutpaint-webUI-extension/app/css/ui/generic.css b/openOutpaint-webUI-extension/app/css/ui/generic.css
new file mode 100644
index 0000000000000000000000000000000000000000..036b905b84ebe7f7cd28e1227c991d1a6c5e8c7b
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/ui/generic.css
@@ -0,0 +1,451 @@
+/* UI Floating Windows */
+.floating-window {
+ position: fixed;
+ width: 250px;
+ height: auto;
+ z-index: 999;
+}
+
+.floating-window-title {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ cursor: move;
+ background-color: rgba(104, 104, 104, 0.75);
+
+ user-select: none;
+
+ padding: 5px;
+ padding-left: 10px;
+
+ margin-bottom: auto;
+ font-size: 1.5em;
+ color: black;
+ text-align: center;
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+ border: solid;
+ border-bottom: none;
+ border-color: black;
+}
+
+.draggable {
+ cursor: move;
+}
+
+/* Slider Input */
+div.slider-wrapper {
+ margin: 5px;
+ margin-left: 0;
+ margin-right: 0;
+}
+
+div.slider-wrapper {
+ position: relative;
+ height: 20px;
+ border-radius: 5px;
+
+ overflow-y: visible;
+}
+
+div.slider-wrapper * {
+ height: 20px;
+ border-radius: 5px;
+ margin: 0;
+}
+
+div.slider-wrapper > * {
+ position: absolute;
+ padding: inherit;
+ width: 100%;
+
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+
+ overflow: hidden;
+}
+
+div.slider-wrapper > div.under {
+ display: flex;
+ background-color: var(--c-primary-accent);
+}
+div.slider-wrapper > div.under > div:first-child {
+ background-color: var(--c-primary);
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+div.slider-wrapper > div.under > div:last-child {
+ flex: 1;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+div.slider-wrapper > div.under > * {
+ height: 100%;
+}
+
+div.slider-wrapper > div.over {
+ cursor: pointer;
+}
+
+div.slider-wrapper > input.text {
+ color: var(--c-text);
+
+ flex: 1;
+ appearance: textfield;
+ border: 0px;
+
+ height: 100%;
+ text-align: center;
+ background-color: transparent;
+}
+
+/* Checkbox Input */
+div.checkbox-array {
+ display: flex;
+
+ margin-top: 5px;
+ margin-bottom: 5px;
+}
+
+input.oo-checkbox[type="checkbox"] {
+ /* Hide original checkbox */
+ -webkit-appearance: none;
+ appearance: none;
+
+ flex: 1;
+
+ margin: 0;
+
+ min-width: 28px;
+ height: 28px;
+
+ background-color: var(--c-primary);
+
+ cursor: pointer;
+}
+
+input.oo-checkbox[type="checkbox"]:disabled {
+ background-color: #666 !important;
+ cursor: default !important;
+}
+
+input.oo-checkbox[type="checkbox"]:disabled:hover {
+ filter: none !important;
+}
+
+input.oo-checkbox[type="checkbox"]:checked::after {
+ background-color: #66f;
+}
+
+input.oo-checkbox[type="checkbox"]:hover {
+ background-color: var(--c-hover);
+}
+
+input.oo-checkbox[type="checkbox"]:active {
+ filter: brightness(120%);
+}
+
+input.oo-checkbox[type="checkbox"]:first-child {
+ border-top-left-radius: 5px;
+ border-bottom-left-radius: 5px;
+}
+
+input.oo-checkbox[type="checkbox"]:last-child {
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+}
+
+/* Mask Inversion Checkbox */
+input.oo-checkbox[type="checkbox"].invert-mask-checkbox::after {
+ background-color: var(--c-text);
+}
+
+input.oo-checkbox[type="checkbox"].invert-mask-checkbox:hover {
+ filter: brightness(120%);
+}
+
+input.oo-checkbox[type="checkbox"].invert-mask-checkbox:active {
+ filter: brightness(140%);
+}
+
+input.oo-checkbox[type="checkbox"].invert-mask-checkbox {
+ background-color: #922;
+}
+
+input.oo-checkbox[type="checkbox"].invert-mask-checkbox:checked {
+ background-color: #229;
+}
+
+/* Bare Select */
+
+.bareselector {
+ border-radius: 5px;
+
+ background-color: white;
+
+ overflow-y: auto;
+
+ margin-top: 0;
+ margin-left: 0;
+
+ max-height: 200px;
+ min-width: 100%;
+ max-width: 800px;
+
+ width: fit-content;
+ z-index: 200;
+}
+
+/* Autocomplete Select */
+div.autocomplete {
+ border-radius: 5px;
+}
+
+div.autocomplete > .autocomplete-text {
+ box-sizing: border-box;
+
+ border-radius: 5px;
+
+ width: 100%;
+}
+
+div.autocomplete > .refreshable {
+ width: 82% !important;
+}
+
+div.autocomplete > .autocomplete-list {
+ position: absolute;
+
+ background-color: white;
+
+ overflow-y: auto;
+
+ margin-top: 0;
+ margin-left: 0;
+
+ max-height: 200px;
+ min-width: 100%;
+ max-width: 800px;
+
+ width: fit-content;
+ z-index: 200;
+}
+
+div.autocomplete > .autocomplete-list > .autocomplete-option {
+ position: relative;
+ cursor: pointer;
+
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ padding: 3px;
+}
+
+div.autocomplete > .autocomplete-list > .autocomplete-option:hover {
+ background-color: #dddf;
+}
+
+div.autocomplete > .autocomplete-list > .autocomplete-option.selected::after {
+ content: "";
+
+ position: absolute;
+ right: 5px;
+ top: 0;
+
+ height: 100%;
+ aspect-ratio: 1;
+
+ background-color: darkgreen;
+
+ -webkit-mask-image: url("../../res/icons/check.svg");
+ -webkit-mask-size: contain;
+ mask-image: url("../../res/icons/check.svg");
+ mask-size: contain;
+}
+
+/* Select Input */
+select > option:checked::after {
+ content: "";
+
+ position: absolute;
+ right: 5px;
+ top: 0;
+
+ height: 100%;
+ aspect-ratio: 1;
+
+ background-color: darkgreen;
+
+ -webkit-mask-image: url("../../res/icons/check.svg");
+ -webkit-mask-size: contain;
+ mask-image: url("../../res/icons/check.svg");
+ mask-size: contain;
+}
+/*************/
+/* UI styles */
+/*************/
+
+/* The separator */
+.ui.separator {
+ width: 80%;
+ margin: auto;
+ align-self: center;
+ border-top: 1px var(--c-hover) solid;
+}
+
+/* Icon button */
+.ui.square {
+ aspect-ratio: 1;
+}
+
+.ui.button {
+ cursor: pointer;
+
+ padding: 0;
+ margin: 0;
+ border: 0;
+ color: var(--c-text);
+ background-color: var(--c-primary);
+ transition-duration: 50ms;
+}
+
+.ui.button:hover {
+ background-color: var(--c-hover);
+}
+
+.ui.button:active {
+ background-color: var(--c-hover);
+ filter: brightness(120%);
+}
+
+.ui.button.icon {
+ display: flex;
+ align-items: stretch;
+}
+
+.ui.button.icon > *:first-child {
+ flex: 1;
+ margin: 3px;
+
+ -webkit-mask-position: center;
+ mask-position: center;
+
+ -webkit-mask-size: contain;
+ mask-size: contain;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ background-color: var(--c-text);
+}
+
+/**
+ * Generic list
+ */
+
+.list {
+ height: 200px;
+
+ overflow-y: auto;
+
+ background-color: var(--c-primary);
+
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+}
+
+.list > *:first-child {
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+}
+
+.list .list-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ height: 25px;
+ padding-left: 5px;
+ padding-right: 5px;
+
+ cursor: pointer;
+
+ color: var(--c-text);
+
+ transition-duration: 50ms;
+}
+
+.list .list-item.active {
+ background-color: var(--c-active);
+}
+.list .list-item.active:hover,
+.list .list-item:hover {
+ background-color: var(--c-hover);
+}
+.list .list-item.active:active,
+.list .list-item:active {
+ background-color: var(--c-hover);
+ filter: brightness(120%);
+}
+
+.list .list-item > .title {
+ flex: 1;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+
+ background-color: transparent;
+
+ border: 0;
+ color: var(--c-text);
+}
+
+.list .list-item > .actions {
+ display: flex;
+ align-self: stretch;
+}
+
+.list .actions > button {
+ display: flex;
+ align-items: stretch;
+
+ padding: 0;
+
+ width: 25px;
+ aspect-ratio: 1;
+
+ background-color: transparent;
+ border: 0;
+ cursor: pointer;
+}
+
+.list .list-item > .actions > *:hover > * {
+ margin: 2px;
+}
+
+.list .actions > button > *:first-child {
+ flex: 1;
+ margin: 3px;
+
+ -webkit-mask-size: contain;
+ mask-size: contain;
+ background-color: var(--c-text);
+}
+
+/* Generic buttons */
+.list .actions > .delete-btn > *:first-child {
+ -webkit-mask-image: url("../../res/icons/trash.svg");
+ mask-image: url("../../res/icons/trash.svg");
+}
+
+.list .actions > .rename-btn > *:first-child {
+ -webkit-mask-image: url("../../res/icons/edit.svg");
+ mask-image: url("../../res/icons/edit.svg");
+}
+
+.list .actions > .download-btn > *:first-child {
+ -webkit-mask-image: url("../../res/icons/download.svg");
+ mask-image: url("../../res/icons/download.svg");
+}
diff --git a/openOutpaint-webUI-extension/app/css/ui/history.css b/openOutpaint-webUI-extension/app/css/ui/history.css
new file mode 100644
index 0000000000000000000000000000000000000000..0c1834f6ad79124998d7b4d68f8b75a9803c1c94
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/ui/history.css
@@ -0,0 +1,38 @@
+#historyContainer > .info {
+ padding: 0;
+}
+
+#history.history {
+ height: 200px;
+ overflow-y: scroll;
+ overflow-x: hidden;
+}
+
+#history.history > .history-item {
+ cursor: pointer;
+
+ padding: 5px;
+ padding-top: 2px;
+ padding-bottom: 2px;
+}
+
+#history.history > .history-item {
+ background-color: #0000;
+}
+#history.history > .history-item:hover {
+ background-color: #fff5;
+}
+
+#history.history > .history-item.current {
+ background-color: #66f5;
+}
+#history.history > .history-item.current:hover {
+ background-color: #66f5;
+}
+
+#history.history > .history-item.future {
+ background-color: #4445;
+}
+#history.history > .history-item.future:hover {
+ background-color: #ddd5;
+}
diff --git a/openOutpaint-webUI-extension/app/css/ui/layers.css b/openOutpaint-webUI-extension/app/css/ui/layers.css
new file mode 100644
index 0000000000000000000000000000000000000000..8adc44c49ec4a1418c7fea985c6cabac0313a1b4
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/ui/layers.css
@@ -0,0 +1,206 @@
+.layer-manager {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+
+ border-radius: 5px;
+ overflow: hidden;
+
+ background-color: var(--c-primary);
+}
+
+#layer-list {
+ height: 200px;
+
+ overflow-y: auto;
+
+ background-color: var(--c-primary);
+
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+}
+
+#layer-list > *:first-child {
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+}
+
+#layer-list .ui-layer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ height: 25px;
+ padding-left: 5px;
+ padding-right: 5px;
+
+ cursor: pointer;
+
+ color: var(--c-text);
+
+ transition-duration: 50ms;
+}
+
+#layer-list .ui-layer.active {
+ background-color: var(--c-active);
+}
+#layer-list .ui-layer.active:hover,
+#layer-list .ui-layer:hover {
+ background-color: var(--c-hover);
+}
+#layer-list .ui-layer.active:active,
+#layer-list .ui-layer:active {
+ background-color: var(--c-hover);
+ filter: brightness(120%);
+}
+
+#layer-list .ui-layer > .title {
+ flex: 1;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+
+ background-color: transparent;
+
+ border: 0;
+ color: var(--c-text);
+}
+
+#layer-list .ui-layer > .actions {
+ display: flex;
+ align-self: stretch;
+}
+
+#layer-list .actions > button {
+ display: flex;
+ align-items: stretch;
+
+ padding: 0;
+
+ width: 25px;
+ aspect-ratio: 1;
+
+ background-color: transparent;
+ border: 0;
+ cursor: pointer;
+}
+
+#layer-list .ui-layer > .actions > *:hover > * {
+ margin: 2px;
+}
+
+#layer-list .actions > button > *:first-child {
+ flex: 1;
+ margin: 3px;
+
+ -webkit-mask-size: contain;
+ mask-size: contain;
+ background-color: var(--c-text);
+}
+
+#layer-list .actions > .rename-btn > *:first-child {
+ -webkit-mask-image: url("../../res/icons/edit.svg");
+ mask-image: url("../../res/icons/edit.svg");
+}
+
+#layer-list .actions > .delete-btn > *:first-child {
+ -webkit-mask-image: url("../../res/icons/trash.svg");
+ mask-image: url("../../res/icons/trash.svg");
+}
+
+#layer-list .actions > .hide-btn > *:first-child {
+ -webkit-mask-image: url("../../res/icons/eye.svg");
+ mask-image: url("../../res/icons/eye.svg");
+}
+#layer-list .hidden .actions > .hide-btn > *:first-child {
+ -webkit-mask-image: url("../../res/icons/eye-off.svg");
+ mask-image: url("../../res/icons/eye-off.svg");
+}
+
+.layer-manager > .separator {
+ width: calc(100% - 10px);
+}
+
+.layer-manager > .layer-list-actions {
+ display: flex;
+ padding: 0;
+
+ justify-content: stretch;
+}
+
+.layer-manager > .layer-list-actions > * {
+ flex: 1;
+ height: 25px;
+}
+
+/* Resizing buttons */
+.expand-button {
+ display: flex;
+
+ align-items: center;
+ justify-content: center;
+
+ margin: 0;
+ padding: 0;
+ border: 0;
+
+ background-color: transparent;
+
+ cursor: pointer;
+
+ transition-duration: 300ms;
+ transition-property: background-color;
+
+ border: 2px solid #293d3d30;
+}
+
+.expand-button::after {
+ content: "";
+
+ background-color: #293d3d77;
+
+ -webkit-mask-image: url("../../res/icons/chevron-up.svg");
+ mask-image: url("../../res/icons/chevron-up.svg");
+ -webkit-mask-size: contain;
+ mask-size: contain;
+
+ width: 60px;
+ height: 60px;
+}
+
+.expand-button:hover::after {
+ background-color: #466;
+}
+
+.expand-button.right::after {
+ transform: rotate(90deg);
+}
+
+.expand-button.bottom::after {
+ transform: rotate(180deg);
+}
+
+.expand-button.left::after {
+ transform: rotate(270deg);
+}
+
+.expand-button.left {
+ border-top-left-radius: 10px;
+ border-bottom-left-radius: 10px;
+}
+.expand-button.top {
+ border-top-left-radius: 10px;
+ border-top-right-radius: 10px;
+}
+.expand-button.right {
+ border-top-right-radius: 10px;
+ border-bottom-right-radius: 10px;
+}
+.expand-button.bottom {
+ border-bottom-right-radius: 10px;
+ border-bottom-left-radius: 10px;
+}
+
+.expand-button:hover {
+ background-color: #293d3d77;
+}
diff --git a/openOutpaint-webUI-extension/app/css/ui/notifications.css b/openOutpaint-webUI-extension/app/css/ui/notifications.css
new file mode 100644
index 0000000000000000000000000000000000000000..3e07b1104117cb74c859781921ef1790dd013315
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/ui/notifications.css
@@ -0,0 +1,253 @@
+/** Notification Ripple */
+@keyframes notification-ripple {
+ 0% {
+ border: none 0px;
+ }
+ 50% {
+ border: solid 5px;
+ }
+ 100% {
+ border: none 0px;
+ }
+}
+
+div.notification-highlight {
+ position: fixed;
+ top: 0;
+ left: 0;
+
+ width: 100%;
+ height: 100%;
+
+ z-index: 1000;
+ pointer-events: none;
+
+ box-sizing: border-box;
+
+ animation: notification-ripple;
+ animation-iteration-count: 1;
+}
+
+.notification-highlight.notification-success {
+ border-color: #39b34999 !important;
+}
+.notification-highlight.notification-warn {
+ border-color: #b3a13999 !important;
+}
+.notification-highlight.notification-info {
+ border-color: #3976b399 !important;
+}
+
+/** Notification area */
+.notification-area {
+ position: absolute;
+
+ width: 250px;
+ min-width: 200px;
+
+ z-index: 25;
+
+ pointer-events: none;
+
+ padding: 10px;
+}
+
+.notification-area > * {
+ pointer-events: all;
+}
+
+.notification-area.bottom-left {
+ left: 0px;
+ bottom: 0px;
+}
+
+/** Notifications */
+.notification-area .notification {
+ position: relative;
+
+ cursor: pointer;
+
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+
+ border-radius: 5px;
+ border: solid 1px;
+
+ padding: 5px;
+ margin-top: 5px;
+
+ color: white;
+}
+
+.notification-area .notification:hover {
+ filter: brightness(110%);
+}
+
+.notification-area .notification:active {
+ filter: brightness(90%);
+}
+
+.notification-area .notification-content {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ max-height: 100%;
+}
+
+.notification-area .notification.expanded .notification-content {
+ white-space: normal !important;
+}
+
+.notification .notification-closebtn {
+ position: relative;
+
+ flex: 0;
+
+ cursor: pointer;
+
+ padding: 0;
+ border: 0;
+
+ min-width: 20px;
+ max-width: 20px;
+ min-height: 20px;
+ max-height: 20px;
+
+ border-radius: 10px;
+
+ background-color: #0002;
+
+ transition-duration: 20ms;
+}
+
+.notification .notification-closebtn:hover {
+ background-color: #fff2;
+}
+
+.notification .notification-closebtn:active {
+ background-color: #fff4;
+}
+
+.notification .notification-closebtn::after {
+ content: "";
+ position: absolute;
+
+ cursor: pointer;
+
+ top: 0;
+ right: 0;
+
+ margin: 2px;
+
+ width: 16px;
+ height: 16px;
+
+ -webkit-mask-size: contain;
+ mask-size: contain;
+
+ -webkit-mask-image: url("../../res/icons/x.svg");
+ mask-image: url("../../res/icons/x.svg");
+
+ background-color: var(--c-text);
+}
+
+/** Notification Types */
+.notification-area .notification.info {
+ background-color: #3976b399;
+ border-color: #12375c;
+}
+
+.notification-area .notification.success {
+ background-color: #39b34999;
+ border-color: #1b5c12;
+}
+
+.notification-area .notification.error {
+ background-color: #b3393999;
+ border-color: #5c1212;
+}
+
+.notification-area .notification.warn {
+ background-color: #b3a13999;
+ border-color: #5c4e12;
+}
+
+/** Dialog */
+.dialog-bg {
+ position: fixed;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+
+ backdrop-filter: blur(5px);
+ background-color: #fff6;
+
+ z-index: 1000;
+}
+
+.dialog-bg .dialog {
+ background-color: var(--c-primary);
+ color: var(--c-text);
+
+ border-radius: 10px;
+
+ position: absolute;
+ margin: auto;
+
+ min-width: 200px;
+ min-height: 20px;
+
+ max-width: 400px;
+}
+
+.dialog .dialog-title {
+ margin: 10px;
+ font-weight: bold;
+}
+
+.dialog .dialog-content {
+ margin: 10px;
+}
+
+.dialog .dialog-choices {
+ display: flex;
+}
+
+.dialog .dialog-choices > *:first-child {
+ border-bottom-left-radius: 10px;
+}
+.dialog .dialog-choices > *:last-child {
+ border-bottom-right-radius: 10px;
+}
+
+.dialog .dialog-choices > * {
+ flex: 1;
+
+ cursor: pointer;
+
+ padding: 5px;
+
+ background-color: transparent;
+ color: var(--c-text);
+
+ border: 0px;
+ border-top: solid 1px var(--c-hover);
+
+ transition-duration: 50ms;
+}
+
+.dialog .dialog-choices > *:not(:first-child) {
+ border-left: solid 1px var(--c-hover);
+}
+
+.dialog .dialog-choices > *:hover {
+ background-color: var(--c-hover);
+}
diff --git a/openOutpaint-webUI-extension/app/css/ui/tool/colorbrush.css b/openOutpaint-webUI-extension/app/css/ui/tool/colorbrush.css
new file mode 100644
index 0000000000000000000000000000000000000000..4fa0d053ed1621c349b7603cd48b37d1d975d7aa
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/ui/tool/colorbrush.css
@@ -0,0 +1,42 @@
+.brush-color-picker.wrapper {
+ position: relative;
+
+ height: 32px;
+
+ display: flex;
+ flex-direction: row;
+ align-items: stretch;
+ justify-content: space-between;
+}
+
+.brush-color-picker.picker {
+ cursor: pointer;
+
+ flex: 1;
+
+ height: 100%;
+
+ border-bottom-left-radius: 3px;
+ border-top-left-radius: 3px;
+
+ padding: 0;
+ border: 0;
+}
+
+.brush-color-picker.eyedropper {
+ cursor: pointer;
+
+ border-radius: 3px;
+ border-bottom-left-radius: 0;
+ border-top-left-radius: 0;
+
+ height: 100%;
+ aspect-ratio: 1;
+
+ border: 0;
+
+ background-image: url("../../../res/icons/pipette.svg");
+ background-repeat: no-repeat;
+ background-position: center;
+ background-size: contain;
+}
diff --git a/openOutpaint-webUI-extension/app/css/ui/tool/dream.css b/openOutpaint-webUI-extension/app/css/ui/tool/dream.css
new file mode 100644
index 0000000000000000000000000000000000000000..b0ac86eaa18b8b428e8007c6011ec23af556500f
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/ui/tool/dream.css
@@ -0,0 +1,3 @@
+.dream-stop-btn {
+ width: 100px;
+}
diff --git a/openOutpaint-webUI-extension/app/css/ui/tool/stamp.css b/openOutpaint-webUI-extension/app/css/ui/tool/stamp.css
new file mode 100644
index 0000000000000000000000000000000000000000..4686e1926eb9c4277c42961e95eb1f2fbf0876b5
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/ui/tool/stamp.css
@@ -0,0 +1,56 @@
+.resource-manager {
+ position: relative;
+ border-radius: 5px;
+}
+
+.resource-manager {
+ user-select: none;
+}
+.resource-manager > .preview-pane {
+ display: none;
+
+ position: absolute;
+
+ height: 200px;
+ width: 200px;
+
+ right: -200px;
+ top: 0px;
+
+ background-color: white;
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center;
+
+ border-radius: 2px;
+}
+
+.resource-manager > .resource-list {
+ height: 200px;
+
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+
+ overflow-y: scroll;
+ overflow-x: hidden;
+}
+
+.resource-manager > .upload-button {
+ display: block;
+ width: 100%;
+
+ padding-left: 0;
+ padding-right: 0;
+
+ margin-top: 0;
+
+ text-align: center;
+
+ border-top-left-radius: 0px;
+ border-top-right-radius: 0px;
+
+ border-bottom-left-radius: 5px;
+ border-bottom-right-radius: 5px;
+
+ border: 0px;
+}
diff --git a/openOutpaint-webUI-extension/app/css/ui/toolbar.css b/openOutpaint-webUI-extension/app/css/ui/toolbar.css
new file mode 100644
index 0000000000000000000000000000000000000000..fa27b5fce27fb2a841e359e56d5f6af35e32c089
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/ui/toolbar.css
@@ -0,0 +1,94 @@
+#ui-toolbar {
+ align-content: center;
+
+ width: 60px;
+
+ border-radius: 5px;
+
+ color: var(--c-text);
+ background-color: var(--c-primary);
+}
+
+#ui-toolbar * {
+ user-select: none;
+}
+
+#ui-toolbar .handle {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ height: 10px;
+}
+
+#ui-toolbar .handle > .line {
+ width: 80%;
+ border-top: 2px #777 dotted;
+}
+
+#ui-toolbar .tool .tool-icon {
+ filter: invert(60%);
+}
+#ui-toolbar .tool.using .tool-icon {
+ filter: invert(80%);
+}
+#ui-toolbar .tool:hover .tool-icon {
+ filter: invert(90%);
+}
+
+/* Toolbar lock indicator */
+#ui-toolbar .lock-indicator {
+ position: absolute;
+ display: none;
+
+ padding: 0;
+
+ right: 2px;
+ top: 10px;
+
+ width: 15px;
+ height: 15px;
+
+ background-color: red;
+
+ -webkit-mask-image: url("../../res/icons/lock.svg");
+ -webkit-mask-size: contain;
+ mask-image: url("../../res/icons/lock.svg");
+ mask-size: contain;
+}
+
+/* The separator */
+#ui-toolbar .separator {
+ width: 80%;
+ margin: auto;
+ align-self: center;
+ border-top: 1px var(--c-hover) solid;
+}
+
+/* Styles for the tool buttons */
+#ui-toolbar .tool {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ aspect-ratio: 1;
+ margin: 5px;
+ border-radius: 5px;
+
+ cursor: pointer;
+
+ transition-duration: 50ms;
+}
+
+#ui-toolbar .tool.using {
+ background-color: var(--c-active);
+}
+
+#ui-toolbar .tool:hover {
+ background-color: var(--c-hover);
+}
+
+#ui-toolbar .tool:active {
+ background-color: var(--c-hover);
+ filter: brightness(120%);
+}
diff --git a/openOutpaint-webUI-extension/app/css/ui/workspace.css b/openOutpaint-webUI-extension/app/css/ui/workspace.css
new file mode 100644
index 0000000000000000000000000000000000000000..159a75e02581df307a6d40b4c58a9635a2627332
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/css/ui/workspace.css
@@ -0,0 +1,59 @@
+#workspace-select input.autocomplete-text {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+
+ padding-left: 5px;
+
+ border: none;
+
+ text-overflow: ellipsis;
+}
+
+#workspace-select-area .buttons > *:last-child {
+ border-top-right-radius: 5px;
+ border-bottom-right-radius: 5px;
+}
+
+.workspace-btn {
+ cursor: pointer;
+
+ border: 0;
+ width: 21px;
+ height: 21px;
+
+ background-color: var(--c-primary);
+}
+
+.workspace-btn:disabled {
+ cursor: default;
+ background-color: var(--c-disabled) !important;
+}
+
+.workspace-btn:hover {
+ background-color: var(--c-hover);
+}
+
+.workspace-btn:active {
+ background-color: var(--c-active);
+}
+
+.workspace-collapsible {
+ position: relative;
+
+ width: 0;
+ overflow: visible;
+}
+
+.workspace-collapsible > *:first-child {
+ display: flex;
+
+ width: fit-content;
+ height: 21px;
+
+ transition-duration: 50ms;
+}
+
+.workspace-collapsible.collapsed > *:first-child {
+ width: 0 !important;
+ overflow: hidden !important;
+}
diff --git a/openOutpaint-webUI-extension/app/defaultscripts.json b/openOutpaint-webUI-extension/app/defaultscripts.json
new file mode 100644
index 0000000000000000000000000000000000000000..3fa06e7919f7f22691560772afaa49f556368771
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/defaultscripts.json
@@ -0,0 +1,16 @@
+{
+ "defaultScripts": {
+ "Loopback": {
+ "titleText": "IMG2IMG ONLY\n\nParams:\nloops (int) //def: 8\ndenoising_strength_change_factor (decimal, 0.90-1.10) //def: 0.99\nappend_interrogation (enum, str: [\"None\",\"CLIP\",\"DeepBooru\"]) //def: None",
+ "scriptValues": "[8, 0.99, \"None\"]"
+ },
+ "Prompt matrix": {
+ "titleText": "Params:\nput_at_start (bool): expect pipe (|) delimited options at start of prompt //def: false\ndifferent_seeds (bool): use different seeds for each picture //def: false\nprompt_type (enum, str: [\"positive\",\"negative\"]) //def: \"positive\"\nvariations_delimiter (enum, str [\"comma\", \"space\"] //def: \"comma\"\nmargin_size (int): px margin value //def: 2",
+ "scriptValues": "[false, false, \"positive\", \"comma\", 2]"
+ },
+ "X/Y/Z plot": {
+ "titleText": "Params:\nx_type (int): index of axis type (see below) //def: 3\nx_values (mixed, str) //def: \"0.00-0.99 [4]\"\nx_values_dropdown (NOIDEA) //def: \"\"\ny_type (int) //def: 4\ny_values (mixed, str) //def: \"5-30 [4]\"\ny_values_dropdown (NOIDEA) //def: \"\"\nz_type (int) //def: 6\nz_values (mixed, str) //def: \"2.5-12.5 [4]\"\nz_values_dropdown (NOIDEA) //def: \"\"\ndraw_legend (bool): return grid of all images //def: false\ninclude_lone_images (bool): return individual images //def: true\ninclude_subgrids (bool) //def: false\nno_fixed_seeds (bool): use different seeds for each picture //def: false\nmargin_size (int): grid margins in px //def: 2\n\nAvailable axis types:\n0: Nothing\n1: Seed\n2: Var. seed\n3: Var. strength\n4: Steps\n5: Hires steps (txt2img only)\n6: CFG Scale\n7: Image CFG Scale (img2img with instructPix2Pix only)\n8: Prompt S/R\n9: Prompt order\n10: Sampler (txt2img only)\n11: Sampler (img2img only)\n12: Checkpoint Name\n13: Negative Guidance minimum sigma\n14: Sigma Churn\n15: Sigma min\n16: Sigma max\n17: Sigma noise\n18: Schedule Type\n19: Schedule min sigma\n20:Schedule max sigma\n21: Schedule rho\n22: Eta\n23: Clip skip\n24: Denoising\n25: Hires upscaler (txt2img only)\n26: Cond. Image Mask Weight (img2img only)\n27: VAE\n23: Styles\n28: UniPC Order\n29: Face Restore\n30: Token merging ratio\n31: Token merging ratio hi-res",
+ "scriptValues": "[3, \"0.00-0.99 [4]\", \"\", 4, \"5-30 [4]\", \"\", 6, \"2.5-12.5 [4]\", \"\", false, true, false, false, 2]"
+ }
+ }
+}
diff --git a/openOutpaint-webUI-extension/app/docs/.DS_Store b/openOutpaint-webUI-extension/app/docs/.DS_Store
new file mode 100644
index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6
Binary files /dev/null and b/openOutpaint-webUI-extension/app/docs/.DS_Store differ
diff --git a/openOutpaint-webUI-extension/app/favicon.ico b/openOutpaint-webUI-extension/app/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..a0ea5e09824686f5c632568c17421dbf3d986292
Binary files /dev/null and b/openOutpaint-webUI-extension/app/favicon.ico differ
diff --git a/openOutpaint-webUI-extension/app/index.html b/openOutpaint-webUI-extension/app/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..33471e0b1f403da046dbff72c0895dffb4663d3c
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/index.html
@@ -0,0 +1,591 @@
+
+
+
+
+ openOutpaint 🐠
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/openOutpaint-webUI-extension/app/js/config.js b/openOutpaint-webUI-extension/app/js/config.js
new file mode 100644
index 0000000000000000000000000000000000000000..85842bdb33ebf446dc87cc712a0844b18c1d66bb
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/config.js
@@ -0,0 +1,52 @@
+/**
+ * This is a file for static unchanging global configurations.
+ *
+ * Do NOT confuse with settings, which are modifiable by either the settings menu, or in the application itself.
+ */
+const config = makeReadOnly(
+ {
+ // Grid Size
+ gridSize: 64,
+
+ // Scroll Tick Limit (How much must scroll to reach next tick)
+ wheelTickSize: 50,
+
+ /** Select Tool */
+ // Handle Draw Size
+ handleDrawSize: 12,
+ // Handle Draw Hover Scale
+ handleDrawHoverScale: 1.3,
+ // Handle Detect Size
+ handleDetectSize: 20,
+ // Rotate Handle Distance (from selection)
+ rotateHandleDistance: 32,
+
+ // Rotation Snapping Distance
+ rotationSnappingDistance: (10 * Math.PI) / 180,
+ // Rotation Snapping Angles
+ rotationSnappingAngles: [
+ (-Math.PI * 4) / 4,
+ (-Math.PI * 3) / 4,
+ (-Math.PI * 2) / 4,
+ (-Math.PI * 1) / 4,
+ 0,
+ (Math.PI * 1) / 4,
+ (Math.PI * 2) / 4,
+ (Math.PI * 3) / 4,
+ (Math.PI * 4) / 4,
+ ],
+
+ // Endpoint
+ api: makeReadOnly({path: "/sdapi/v1/"}),
+
+ // Default notification timeout
+ notificationTimeout: 8000,
+ notificationHighlightAnimationDuration: 200,
+
+ /**
+ * Interrogate Tool
+ */
+ interrogateToolNotificationTimeout: 120000, // Default is two minutes
+ },
+ "config"
+);
diff --git a/openOutpaint-webUI-extension/app/js/defaults.js b/openOutpaint-webUI-extension/app/js/defaults.js
new file mode 100644
index 0000000000000000000000000000000000000000..cb6d7b9321e92a6ea4499e0fe5ce3d439313de1c
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/defaults.js
@@ -0,0 +1,28 @@
+/**
+ * Default settings for local configurations
+ */
+const localDefaults = {
+ /** Default Host */
+ host: "http://127.0.0.1:7860",
+};
+
+/**
+ * Default settings for workspace configurations
+ */
+const workspaceDefaults = {
+ /** Default Prompt - REQ */
+ prompt: "ocean floor scientific expedition, underwater wildlife",
+ /** Default Negative Prompt - REQ */
+ neg_prompt:
+ "people, person, humans, human, divers, diver, glitch, error, text, watermark, bad quality, blurry",
+ /** Default Stable Diffusion Seed - REQ */
+ seed: -1,
+
+ /** Default CFG Scale - REQ */
+ cfg_scale: 7.0,
+ /** Default steps - REQ */
+ steps: 30,
+
+ /** Default Resolution */
+ resolution: 512,
+};
diff --git a/openOutpaint-webUI-extension/app/js/extensions.js b/openOutpaint-webUI-extension/app/js/extensions.js
new file mode 100644
index 0000000000000000000000000000000000000000..b4e8448db0de3334cb11caae14325190e5b26499
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/extensions.js
@@ -0,0 +1,196 @@
+/**
+ * Extensions helper thing or class or whatever
+ */
+
+const extensions = {
+ // alwaysOnScriptsData: {},
+ alwaysOnScripts: false,
+ controlNetEnabled: false,
+ controlNetActive: false,
+ controlNetReferenceActive: false,
+ controlNetReferenceFidelity: 0.5,
+ selectedControlNetModel: null,
+ selectedControlNetModule: null,
+ selectedCNReferenceModule: null,
+ controlNetModelCount: 0,
+ dynamicPromptsEnabled: false,
+ dynamicPromptsActive: false,
+ dynamicPromptsAlwaysonScriptName: null, //GRUMBLE GRUMBLE
+ enabledExtensions: [],
+ controlNetModels: null,
+ controlNetModules: null,
+
+ async getExtensions(
+ controlNetModelAutoComplete,
+ controlNetModuleAutoComplete,
+ controlNetReferenceModuleAutoComplete
+ ) {
+ const allowedExtensions = [
+ "controlnet",
+ // "none",
+ // "adetailer", // no API, can't verify available models
+ "dynamic prompts", //seriously >:( why put version in the name, now i have to fuzzy match it - just simply enabled or not? no API but so so good
+ //"segment anything", // ... API lets me get model but not processor?!?!?!
+ //"self attention guidance", // no API but useful, just enabled button, scale and threshold sliders?
+ ];
+ // check http://127.0.0.1:7860/sdapi/v1/scripts for extensions
+ // if any of the allowed extensions are found, add them to the list
+ var url = document.getElementById("host").value + "/sdapi/v1/scripts";
+ try {
+ const response = await fetch(url);
+ const data = await response.json();
+ // enable checkboxes for extensions based on existence in data
+ data.img2img
+ .filter((extension) => {
+ return allowedExtensions.some((allowedExtension) => {
+ return extension.toLowerCase().includes(allowedExtension);
+ });
+ })
+ .forEach((extension) => {
+ this.enabledExtensions.push(extension);
+ });
+ } catch (e) {
+ console.warn("[index] Failed to fetch extensions");
+ console.warn(e);
+ }
+ this.checkForDynamicPrompts();
+ this.checkForControlNet(
+ controlNetModelAutoComplete,
+ controlNetModuleAutoComplete,
+ controlNetReferenceModuleAutoComplete
+ );
+ //checkForSAM(); //or inpaintAnything or something i dunno
+ //checkForADetailer(); //? this one seems iffy
+ //checkForSAG(); //??
+ },
+
+ async checkForDynamicPrompts() {
+ if (
+ this.enabledExtensions.filter((e) => e.includes("dynamic prompts"))
+ .length > 0
+ ) {
+ // Dynamic Prompts found, enable checkbox
+ this.alwaysOnScripts = true;
+ this.dynamicPromptsAlwaysonScriptName =
+ this.enabledExtensions[
+ this.enabledExtensions.findIndex((e) => e.includes("dynamic prompts"))
+ ];
+ // this.alwaysOnScriptsData[this.dynamicPromptsAlwaysonScriptName] = {};
+ this.dynamicPromptsEnabled = true;
+ document.getElementById("cbxDynPrompts").disabled = false;
+ }
+ // basically param 0 is true for on, false for off, that's it
+ },
+
+ async checkForControlNet(
+ controlNetModelAutoComplete,
+ controlNetModuleAutoComplete,
+ controlNetReferenceModuleAutoComplete
+ ) {
+ var url = document.getElementById("host").value + "/controlnet/version";
+
+ if (
+ this.enabledExtensions.filter((e) => e.includes("controlnet")).length > 0
+ ) {
+ try {
+ const response = await fetch(url);
+ const data = await response.json();
+
+ if (data.version > 0) {
+ // ControlNet found
+ this.alwaysOnScripts = true;
+ this.controlNetEnabled = true;
+ document.getElementById("cbxControlNet").disabled = false;
+ // ok cool so now we can get the models and modules
+ this.getModels(controlNetModelAutoComplete);
+ this.getModules(
+ controlNetModuleAutoComplete,
+ controlNetReferenceModuleAutoComplete
+ );
+ }
+ url = document.getElementById("host").value + "/controlnet/settings";
+ try {
+ const response2 = await fetch(url);
+ const data2 = await response2.json();
+ if (data2.control_net_max_models_num < 2) {
+ document.getElementById("cbxControlNetReferenceLayer").disabled =
+ "disabled";
+ console.warn(
+ "[extensions] ControlNet reference layer disabled due to insufficient units enabled in settings - cannot be enabled via API, please increase to at least 2 units manually"
+ );
+ }
+ } catch (ex) {}
+ } catch (e) {
+ // ??
+ global.controlnetAPI = false;
+ }
+ } else {
+ global.controlnetAPI = false;
+ }
+ },
+ async getModels(controlNetModelAutoComplete) {
+ // only worry about inpaint models for now
+ var url = document.getElementById("host").value + "/controlnet/model_list";
+
+ try {
+ const response = await fetch(url);
+ const data = await response.json();
+
+ this.controlNetModels = data.model_list;
+ } catch (e) {
+ console.warn("[extensions] Failed to fetch controlnet models");
+ console.warn(e);
+ }
+
+ let opt = null;
+ opt = this.controlNetModels
+ .filter((m) => m.includes("inpaint"))
+ .map((option) => ({
+ name: option,
+ value: option,
+ }));
+
+ controlNetModelAutoComplete.options = opt;
+ },
+ async getModules(
+ controlNetModuleAutoComplete,
+ controlNetReferenceModuleAutoComplete
+ ) {
+ const allowedModules = ["reference", "inpaint"];
+ var url = document.getElementById("host").value + "/controlnet/module_list";
+
+ try {
+ const response = await fetch(url);
+ const data = await response.json();
+
+ this.controlNetModules = data;
+ } catch (e) {
+ console.warn("[extensions] Failed to fetch controlnet modules");
+ console.warn(e);
+ }
+
+ let opt = null;
+ opt = this.controlNetModules.module_list
+ .filter((m) => m.includes("inpaint")) // why is there just "inpaint" in the modules if it's not in the ui
+ .map((option) => ({
+ name: option,
+ value: option,
+ }));
+
+ opt.push({
+ name: "inpaint_global_harmonious",
+ value: "inpaint_global_harmonious", // WTF WHY IS THIS ONE NOT LISTED IN MODULES BUT DISTINCT IN THE API CALL?!?!?!??!??! it is slightly different from "inpaint" from what i can tell
+ });
+
+ controlNetModuleAutoComplete.options = opt;
+
+ opt = this.controlNetModules.module_list
+ .filter((m) => m.includes("reference"))
+ .map((option) => ({
+ name: option,
+ value: option,
+ }));
+
+ controlNetReferenceModuleAutoComplete.options = opt;
+ },
+};
diff --git a/openOutpaint-webUI-extension/app/js/global.js b/openOutpaint-webUI-extension/app/js/global.js
new file mode 100644
index 0000000000000000000000000000000000000000..446758f4306b42ed8a6d24b1dfe4401417476110
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/global.js
@@ -0,0 +1,61 @@
+/**
+ * Stores global variables without polluting the global namespace.
+ */
+
+const global = {
+ // If this is the first run of openOutpaint
+ get firstRun() {
+ return this._firstRun;
+ },
+
+ // Connection
+ _connection: "offline",
+ set connection(v) {
+ this._connection = v;
+
+ toolbar &&
+ toolbar.currentTool &&
+ toolbar.currentTool.state.redraw &&
+ toolbar.currentTool.state.redraw();
+ },
+ get connection() {
+ return this._connection;
+ },
+
+ // If there is a selected input
+ hasActiveInput: false,
+
+ // If cursor size sync is enabled
+ syncCursorSize: false,
+
+ // If debugging is enabled
+ _debug: false,
+ set debug(v) {
+ if (debugLayer) {
+ if (v) {
+ debugLayer.unhide();
+ } else {
+ debugLayer.hide();
+ }
+ }
+
+ this._debug = v;
+ },
+ get debug() {
+ return this._debug;
+ },
+ /**
+ * Toggles debugging.
+ */
+ toggledebug() {
+ this.debug = !this.debug;
+ },
+
+ // HRFix compatibility shenanigans
+ isOldHRFix: false,
+
+ // WebUI object to communitate with parent window
+ webui: null,
+};
+
+global._firstRun = !localStorage.getItem("openoutpaint/host");
diff --git a/openOutpaint-webUI-extension/app/js/index.js b/openOutpaint-webUI-extension/app/js/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..02078ad7248144f77554fbdea79135b8d40a086c
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/index.js
@@ -0,0 +1,1685 @@
+//TODO FIND OUT WHY I HAVE TO RESIZE A TEXTBOX AND THEN START USING IT TO AVOID THE 1px WHITE LINE ON LEFT EDGES DURING IMG2IMG
+//...lmao did setting min width 200 on info div fix that accidentally? once the canvas is infinite and the menu bar is hideable it'll probably be a problem again
+
+/**
+ * Workaround for Firefox bug #733698
+ *
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=733698
+ *
+ * Workaround by https://github.com/subzey on https://gist.github.com/subzey/2030480
+ *
+ * Replaces and handles NS_ERROR_FAILURE errors triggered by 733698.
+ */
+(function () {
+ var FakeTextMetrics,
+ proto,
+ fontSetterNative,
+ measureTextNative,
+ fillTextNative,
+ strokeTextNative;
+
+ if (
+ !window.CanvasRenderingContext2D ||
+ !window.TextMetrics ||
+ !(proto = window.CanvasRenderingContext2D.prototype) ||
+ !proto.hasOwnProperty("font") ||
+ !proto.hasOwnProperty("mozTextStyle") ||
+ typeof proto.__lookupSetter__ !== "function" ||
+ !(fontSetterNative = proto.__lookupSetter__("font"))
+ ) {
+ return;
+ }
+
+ proto.__defineSetter__("font", function (value) {
+ try {
+ return fontSetterNative.call(this, value);
+ } catch (e) {
+ if (e.name !== "NS_ERROR_FAILURE") {
+ throw e;
+ }
+ }
+ });
+
+ measureTextNative = proto.measureText;
+ FakeTextMetrics = function () {
+ this.width = 0;
+ this.isFake = true;
+ this.__proto__ = window.TextMetrics.prototype;
+ };
+ proto.measureText = function ($0) {
+ try {
+ return measureTextNative.apply(this, arguments);
+ } catch (e) {
+ if (e.name !== "NS_ERROR_FAILURE") {
+ throw e;
+ } else {
+ return new FakeTextMetrics();
+ }
+ }
+ };
+
+ fillTextNative = proto.fillText;
+ proto.fillText = function ($0, $1, $2, $3) {
+ try {
+ fillTextNative.apply(this, arguments);
+ } catch (e) {
+ if (e.name !== "NS_ERROR_FAILURE") {
+ throw e;
+ }
+ }
+ };
+
+ strokeTextNative = proto.strokeText;
+ proto.strokeText = function ($0, $1, $2, $3) {
+ try {
+ strokeTextNative.apply(this, arguments);
+ } catch (e) {
+ if (e.name !== "NS_ERROR_FAILURE") {
+ throw e;
+ }
+ }
+ };
+})();
+
+// Parse url parameters
+const urlParams = new URLSearchParams(window.location.search);
+
+window.onload = startup;
+
+var stableDiffusionData = {
+ //includes img2img data but works for txt2img just fine
+ prompt: "",
+ negative_prompt: "",
+ seed: -1,
+ cfg_scale: null,
+ sampler_index: "DDIM",
+ steps: null,
+ denoising_strength: 1,
+ mask_blur: 0,
+ batch_size: null,
+ width: 512,
+ height: 512,
+ n_iter: null, // batch count
+ mask: "",
+ init_images: [],
+ inpaint_full_res: false,
+ inpainting_fill: 1,
+ outpainting_fill: 2,
+ enable_hr: false,
+ restore_faces: false,
+ //firstphase_width: 0,
+ //firstphase_height: 0, //20230102 welp looks like the entire way HRfix is implemented has become bonkersly different
+ hr_scale: 2.0,
+ hr_upscaler: "None",
+ hr_second_pass_steps: 0,
+ hr_resize_x: 0,
+ hr_resize_y: 0,
+ hr_square_aspect: false,
+ styles: [],
+ // here's some more fields that might be useful
+
+ // ---txt2img specific:
+ // "enable_hr": false, // hires fix
+ // "denoising_strength": 0, // ok this is in both txt and img2img but txt2img only applies it if enable_hr == true
+ // "firstphase_width": 0, // hires fp w
+ // "firstphase_height": 0, // see above s/w/h/
+
+ // ---img2img specific
+ // "init_images": [ // imageS!??!? wtf maybe for batch img2img?? i just dump one base64 in here
+ // "string"
+ // ],
+ // "resize_mode": 0,
+ // "denoising_strength": 0.75, // yeah see
+ // "mask": "string", // string is just a base64 image
+ // "mask_blur": 4,
+ // "inpainting_fill": 0, // 0- fill, 1- orig, 2- latent noise, 3- latent nothing
+ // "inpaint_full_res": true,
+ // "inpaint_full_res_padding": 0, // px
+ // "inpainting_mask_invert": 0, // bool??????? wtf
+ // "include_init_images": false // ??????
+};
+
+// stuff things use
+var host = "";
+var url = "/sdapi/v1/";
+const basePixelCount = 64; //64 px - ALWAYS 64 PX
+var focused = true;
+let defaultScripts = {};
+let userScripts = {};
+
+function startup() {
+ testHostConfiguration();
+ loadSettings();
+
+ const hostEl = document.getElementById("host");
+ testHostConnection().then((checkConnection) => {
+ hostEl.onchange = () => {
+ host = hostEl.value.endsWith("/")
+ ? hostEl.value.substring(0, hostEl.value.length - 1)
+ : hostEl.value;
+ hostEl.value = host;
+ localStorage.setItem("openoutpaint/host", host);
+ checkConnection();
+ };
+ });
+
+ drawBackground();
+ changeMaskBlur();
+ changeSmoothRendering();
+ changeSeed();
+ changeHiResFix();
+ changeHiResSquare();
+ changeRestoreFaces();
+ changeSyncCursorSize();
+ changeControlNetExtension();
+ changeControlNetReference();
+ checkFocus();
+ refreshScripts();
+}
+
+function setFixedHost(h, changePromptMessage) {
+ console.info(`[index] Fixed host to '${h}'`);
+ const hostInput = document.getElementById("host");
+ host = h;
+ hostInput.value = h;
+ hostInput.readOnly = true;
+ hostInput.style.cursor = "default";
+ hostInput.style.backgroundColor = "#ddd";
+ hostInput.addEventListener("dblclick", async () => {
+ if (await notifications.dialog("Host is Locked", changePromptMessage)) {
+ hostInput.style.backgroundColor = null;
+ hostInput.style.cursor = null;
+ hostInput.readOnly = false;
+ hostInput.focus();
+ }
+ });
+}
+
+/**
+ * Initial connection checks
+ */
+function testHostConfiguration() {
+ /**
+ * Check host configuration
+ */
+ const hostEl = document.getElementById("host");
+ hostEl.value = localStorage.getItem("openoutpaint/host");
+
+ const requestHost = (prompt, def = "http://127.0.0.1:7860") => {
+ let value = null;
+
+ if (!urlParams.has("noprompt")) value = window.prompt(prompt, def);
+ if (value === null) value = def;
+
+ value = value.endsWith("/") ? value.substring(0, value.length - 1) : value;
+ host = value;
+ hostEl.value = host;
+ localStorage.setItem("openoutpaint/host", host);
+ };
+
+ const current = localStorage.getItem("openoutpaint/host");
+ if (current) {
+ if (!current.match(/^https?:\/\/[a-z0-9][a-z0-9.]+[a-z0-9](:[0-9]+)?$/i))
+ requestHost(
+ "Host seems to be invalid! Please fix your host here:",
+ current
+ );
+ else
+ host = current.endsWith("/")
+ ? current.substring(0, current.length - 1)
+ : current;
+ } else {
+ requestHost(
+ "This seems to be the first time you are using openOutpaint! Please set your host here:"
+ );
+ }
+}
+
+async function testHostConnection() {
+ class CheckInProgressError extends Error {}
+
+ const connectionIndicator = document.getElementById(
+ "connection-status-indicator"
+ );
+
+ let connectionStatus = false;
+ let firstTimeOnline = true;
+
+ const setConnectionStatus = (status) => {
+ const connectionIndicatorText = document.getElementById(
+ "connection-status-indicator-text"
+ );
+
+ const statuses = {
+ online: () => {
+ connectionIndicator.classList.add("online");
+ connectionIndicator.classList.remove(
+ "webui-issue",
+ "offline",
+ "before",
+ "server-error"
+ );
+ connectionIndicatorText.textContent = connectionIndicator.title =
+ "Connected";
+ connectionStatus = true;
+ },
+ error: () => {
+ connectionIndicator.classList.add("server-error");
+ connectionIndicator.classList.remove(
+ "online",
+ "offline",
+ "before",
+ "webui-issue"
+ );
+ connectionIndicatorText.textContent = "Error";
+ connectionIndicator.title =
+ "Server is online, but is returning an error response";
+ connectionStatus = false;
+ },
+ corsissue: () => {
+ connectionIndicator.classList.add("webui-issue");
+ connectionIndicator.classList.remove(
+ "online",
+ "offline",
+ "before",
+ "server-error"
+ );
+ connectionIndicatorText.textContent = "CORS";
+ connectionIndicator.title =
+ "Server is online, but CORS is blocking our requests";
+ connectionStatus = false;
+ },
+ apiissue: () => {
+ connectionIndicator.classList.add("webui-issue");
+ connectionIndicator.classList.remove(
+ "online",
+ "offline",
+ "before",
+ "server-error"
+ );
+ connectionIndicatorText.textContent = "API";
+ connectionIndicator.title =
+ "Server is online, but the API seems to be disabled";
+ connectionStatus = false;
+ },
+ offline: () => {
+ connectionIndicator.classList.add("offline");
+ connectionIndicator.classList.remove(
+ "webui-issue",
+ "online",
+ "before",
+ "server-error"
+ );
+ connectionIndicatorText.textContent = "Offline";
+ connectionIndicator.title =
+ "Server seems to be offline. Please check the console for more information.";
+ connectionStatus = false;
+ },
+ before: () => {
+ connectionIndicator.classList.add("before");
+ connectionIndicator.classList.remove(
+ "webui-issue",
+ "online",
+ "offline",
+ "server-error"
+ );
+ connectionIndicatorText.textContent = "Waiting";
+ connectionIndicator.title = "Waiting for check to complete.";
+ connectionStatus = false;
+ },
+ };
+
+ statuses[status] &&
+ (() => {
+ statuses[status]();
+ global.connection = status;
+ })();
+ };
+
+ setConnectionStatus("before");
+
+ let checkInProgress = false;
+
+ const checkConnection = async (
+ notify = false,
+ simpleProgressStatus = false
+ ) => {
+ const apiIssueResult = () => {
+ setConnectionStatus("apiissue");
+ const message = `The host is online, but the API seems to be disabled. Have you run the webui with the flag '--api', or is the flag '--gradio-debug' currently active?`;
+ console.error(message);
+ if (notify)
+ notifications.notify(message, {
+ type: NotificationType.ERROR,
+ timeout: config.notificationTimeout * 2,
+ });
+ };
+
+ const offlineResult = () => {
+ setConnectionStatus("offline");
+ const message = `The connection with the host returned an error: ${response.status} - ${response.statusText}`;
+ console.error(message);
+ if (notify)
+ notifications.notify(message, {
+ type: NotificationType.ERROR,
+ timeout: config.notificationTimeout * 2,
+ });
+ };
+ if (checkInProgress)
+ throw new CheckInProgressError(
+ "Check is currently in progress, please try again"
+ );
+ checkInProgress = true;
+ var url = document.getElementById("host").value + "/startup-events";
+ // Attempt normal request
+ try {
+ if (simpleProgressStatus) {
+ const response = await fetch(
+ document.getElementById("host").value + "/sdapi/v1/progress" // seems to be the "lightest" endpoint?
+ );
+ switch (response.status) {
+ case 200: {
+ setConnectionStatus("online");
+ break;
+ }
+ case 404: {
+ apiIssueResult();
+ break;
+ }
+ default: {
+ offlineResult();
+ }
+ }
+ } else {
+ // Check if API is available
+ const response = await fetch(
+ document.getElementById("host").value + "/sdapi/v1/options"
+ );
+ const optionsdata = await response.json();
+ if (optionsdata["use_scale_latent_for_hires_fix"]) {
+ const message = `You are using an outdated version of A1111 webUI. The HRfix options will not work until you update to at least commit ef27a18 or newer. (https://github.com/AUTOMATIC1111/stable-diffusion-webui/commit/ef27a18b6b7cb1a8eebdc9b2e88d25baf2c2414d) HRfix will fallback to half-resolution only.`;
+ console.warn(message);
+ if (notify)
+ notifications.notify(message, {
+ type: NotificationType.WARN,
+ timeout: config.notificationTimeout * 4,
+ });
+ // Hide all new hrfix options
+ document
+ .querySelectorAll(".hrfix")
+ .forEach((el) => (el.style.display = "none"));
+
+ // We are using old HRFix
+ global.isOldHRFix = true;
+ stableDiffusionData.enable_hr = false;
+ }
+ switch (response.status) {
+ case 200: {
+ setConnectionStatus("online");
+ // Load data as soon as connection is first stablished
+ if (firstTimeOnline) {
+ getConfig();
+ getStyles();
+ getSamplers();
+ getUpscalers();
+ getModels();
+ extensions.getExtensions(
+ controlNetModelAutoComplete,
+ controlNetModuleAutoComplete,
+ controlNetReferenceModuleAutoComplete
+ );
+ getLoras();
+ // getTIEmbeddings();
+ // getHypernets();
+ firstTimeOnline = false;
+ }
+ break;
+ }
+ case 404: {
+ apiIssueResult();
+ break;
+ }
+ default: {
+ offlineResult();
+ }
+ }
+ }
+ } catch (e) {
+ try {
+ if (e instanceof DOMException) throw "offline";
+ // Tests if problem is CORS
+ await fetch(url, {mode: "no-cors"});
+
+ setConnectionStatus("corsissue");
+ const message = `CORS is blocking our requests. Try running the webui with the flag '--cors-allow-origins=${window.location.protocol}//${window.location.host}/'`;
+ console.error(message);
+ if (notify)
+ notifications.notify(message, {
+ type: NotificationType.ERROR,
+ timeout: config.notificationTimeout * 2,
+ });
+ } catch (e) {
+ setConnectionStatus("offline");
+ const message = `The server seems to be offline. Is host '${
+ document.getElementById("host").value
+ }' correct?`;
+ console.error(message);
+ if (notify)
+ notifications.notify(message, {
+ type: NotificationType.ERROR,
+ timeout: config.notificationTimeout * 2,
+ });
+ }
+ }
+ checkInProgress = false;
+ return status;
+ };
+
+ if (focused || firstTimeOnline) {
+ await checkConnection(!urlParams.has("noprompt"));
+ }
+
+ // On click, attempt to refresh
+ connectionIndicator.onclick = async () => {
+ try {
+ await checkConnection(true);
+ checked = true;
+ } catch (e) {
+ console.debug("Already refreshing");
+ }
+ };
+
+ // Checks every 5 seconds if offline, 60 seconds if online
+ const checkAgain = () => {
+ checkFocus();
+ if (focused || firstTimeOnline) {
+ setTimeout(
+ async () => {
+ let simple = !firstTimeOnline;
+ await checkConnection(false, simple);
+ checkAgain();
+ },
+ connectionStatus ? 60000 : 5000
+ );
+ } else {
+ setTimeout(() => {
+ checkAgain();
+ }, 60000);
+ }
+ };
+
+ checkAgain();
+
+ return () => {
+ checkConnection().catch(() => {});
+ };
+}
+
+function newImage(evt) {
+ clearPaintedMask();
+ uil.layers.forEach(({layer}) => {
+ commands.runCommand(
+ "eraseImage",
+ "Clear Canvas",
+ {
+ ...layer.bb,
+ ctx: layer.ctx,
+ },
+ {
+ extra: {
+ log: `Cleared Canvas`,
+ },
+ }
+ );
+ });
+}
+
+function clearPaintedMask() {
+ maskPaintLayer.clear();
+}
+
+function march(bb, options = {}) {
+ defaultOpt(options, {
+ title: null,
+ titleStyle: "#FFF5",
+ style: "#FFFF",
+ width: "2px",
+ filter: null,
+ });
+
+ const expanded = {...bb};
+ expanded.x--;
+ expanded.y--;
+ expanded.w += 2;
+ expanded.h += 2;
+
+ // Get temporary layer to draw marching ants
+ const layer = imageCollection.registerLayer(null, {
+ bb: expanded,
+ category: "display",
+ });
+ layer.canvas.style.imageRendering = "pixelated";
+ let offset = 0;
+
+ const interval = setInterval(() => {
+ drawMarchingAnts(layer.ctx, bb, offset++, options);
+ offset %= 12;
+ }, 20);
+
+ return () => {
+ clearInterval(interval);
+ imageCollection.deleteLayer(layer);
+ };
+}
+
+function drawMarchingAnts(ctx, bb, offset, options) {
+ ctx.save();
+
+ ctx.clearRect(0, 0, bb.w + 2, bb.h + 2);
+
+ // Draw Tool Name
+ if (bb.h > 40 && options.title) {
+ ctx.font = `bold 20px Open Sans`;
+
+ ctx.textAlign = "left";
+ ctx.fillStyle = options.titleStyle;
+ ctx.fillText(options.title, 10, 30, bb.w);
+ }
+
+ ctx.strokeStyle = options.style;
+ ctx.strokeWidth = options.width;
+ ctx.filter = options.filter;
+ ctx.setLineDash([4, 2]);
+ ctx.lineDashOffset = -offset;
+ ctx.strokeRect(1, 1, bb.w, bb.h);
+
+ ctx.restore();
+}
+
+const makeSlider = (
+ label,
+ el,
+ lsKey,
+ min,
+ max,
+ step,
+ defaultValue,
+ textStep = null,
+ valuecb = null
+) => {
+ const local = lsKey && localStorage.getItem(`openoutpaint/${lsKey}`);
+ const def = parseFloat(local === null ? defaultValue : local);
+ let cb = (v) => {
+ stableDiffusionData[lsKey] = v;
+ if (lsKey) localStorage.setItem(`openoutpaint/${lsKey}`, v);
+ };
+ if (valuecb) {
+ cb = (v) => {
+ valuecb(v);
+ localStorage.setItem(`openoutpaint/${lsKey}`, v);
+ };
+ }
+ return createSlider(label, el, {
+ valuecb: cb,
+ min,
+ max,
+ step,
+ defaultValue: def,
+ textStep,
+ });
+};
+
+let modelAutoComplete = createAutoComplete(
+ "Model",
+ document.getElementById("models-ac-select"),
+ {},
+ document.getElementById("refreshModelsBtn"),
+ "refreshable"
+);
+modelAutoComplete.onchange.on(({value}) => {
+ /**
+ * TODO implement optional API call to check model unet channel count
+ * extension users guaranteed to have it as of
+ * https://github.com/zero01101/openOutpaint-webUI-extension/commit/1f22f5ea5b860c6e91f77edfb47743a124596dec
+ * but still need a fallback check like below
+ */
+
+ if (value.toLowerCase().includes("inpainting"))
+ document.querySelector(
+ "#models-ac-select input.autocomplete-text"
+ ).style.backgroundColor = "#cfc";
+ else
+ document.querySelector(
+ "#models-ac-select input.autocomplete-text"
+ ).style.backgroundColor = "#fcc";
+});
+
+let loraAutoComplete = createAutoComplete(
+ "LoRa",
+ document.getElementById("lora-ac-select")
+);
+loraAutoComplete.onchange.on(({value}) => {
+ // add selected lora to the end of the prompt
+ let passVal = " ";
+ let promptInput = document.getElementById("prompt");
+ promptInput.value += passVal;
+ let promptThing = prompt;
+});
+
+const samplerAutoComplete = createAutoComplete(
+ "Sampler",
+ document.getElementById("sampler-ac-select")
+);
+
+const upscalerAutoComplete = createAutoComplete(
+ "Upscaler",
+ document.getElementById("upscaler-ac-select")
+);
+
+const hrFixUpscalerAutoComplete = createAutoComplete(
+ "HRfix Upscaler",
+ document.getElementById("hrFixUpscaler")
+);
+
+let controlNetModelAutoComplete = createAutoComplete(
+ "Inpaint Model",
+ document.getElementById("controlNetModel-ac-select")
+);
+
+controlNetModelAutoComplete.onchange.on(({value}) => {
+ extensions.selectedControlNetModel = value;
+});
+
+let controlNetModuleAutoComplete = createAutoComplete(
+ "Inpaint Preprocessor",
+ document.getElementById("controlNetModule-ac-select")
+);
+
+controlNetModuleAutoComplete.onchange.on(({value}) => {
+ extensions.selectedControlNetModule = value;
+});
+
+let controlNetReferenceModuleAutoComplete = createAutoComplete(
+ "Reference Preprocessor",
+ document.getElementById("controlNetReferenceModule-ac-select")
+);
+
+controlNetReferenceModuleAutoComplete.onchange.on(({value}) => {
+ extensions.selectedCNReferenceModule = value;
+});
+
+// const extensionsAutoComplete = createAutoComplete(
+// "Extension",
+// document.getElementById("extension-ac-select")
+// );
+
+hrFixUpscalerAutoComplete.onchange.on(({value}) => {
+ stableDiffusionData.hr_upscaler = value;
+ localStorage.setItem(`openoutpaint/hr_upscaler`, value);
+});
+
+const resSlider = makeSlider(
+ "Resolution",
+ document.getElementById("resolution"),
+ "resolution",
+ 128,
+ 2048,
+ 128,
+ 512,
+ 2,
+ (v) => {
+ stableDiffusionData.width = stableDiffusionData.height = v;
+
+ toolbar.currentTool &&
+ toolbar.currentTool.redraw &&
+ toolbar.currentTool.redraw();
+ }
+);
+
+const refSlider = makeSlider(
+ "Reference Fidelity",
+ document.getElementById("controlNetReferenceFidelity"),
+ "cn_reference_fidelity",
+ 0.0,
+ 1.0,
+ 0.1,
+ 0.5,
+ 0.01,
+ (v) => {
+ extensions.controlNetReferenceFidelity = v;
+
+ toolbar.currentTool &&
+ toolbar.currentTool.redraw &&
+ toolbar.currentTool.redraw();
+ }
+);
+
+makeSlider(
+ "CFG Scale",
+ document.getElementById("cfgScale"),
+ "cfg_scale",
+ localStorage.getItem("openoutpaint/settings.min-cfg") || 1,
+ localStorage.getItem("openoutpaint/settings.max-cfg") || 25,
+ 0.5,
+ 7.0,
+ 0.1
+);
+makeSlider(
+ "Batch Size",
+ document.getElementById("batchSize"),
+ "batch_size",
+ 1,
+ 8,
+ 1,
+ 2
+);
+makeSlider(
+ "Iterations",
+ document.getElementById("batchCount"),
+ "n_iter",
+ 1,
+ 8,
+ 1,
+ 2
+);
+makeSlider(
+ "Upscale X",
+ document.getElementById("upscaleX"),
+ "upscale_x",
+ 1.0,
+ 4.0,
+ 0.1,
+ 2.0,
+ 0.1
+);
+
+makeSlider(
+ "Steps",
+ document.getElementById("steps"),
+ "steps",
+ 1,
+ localStorage.getItem("openoutpaint/settings.max-steps") || 70,
+ 5,
+ 30,
+ 1
+);
+
+// 20230102 grumble grumble
+const hrFixScaleSlider = makeSlider(
+ "HRfix Scale",
+ document.getElementById("hrFixScale"),
+ "hr_scale",
+ 1.0,
+ 4.0,
+ 0.1,
+ 2.0,
+ 0.1
+);
+
+makeSlider(
+ "HRfix Denoising",
+ document.getElementById("hrDenoising"),
+ "hr_denoising_strength",
+ 0.0,
+ 1.0,
+ 0.05,
+ 0.7,
+ 0.01
+);
+
+const lockPxSlider = makeSlider(
+ "HRfix Autoscale Lock Px.",
+ document.getElementById("hrFixLockPx"),
+ "hr_fix_lock_px",
+ 0,
+ 1024,
+ 256,
+ 0,
+ 1
+);
+
+const hrStepsSlider = makeSlider(
+ "HRfix Steps",
+ document.getElementById("hrFixSteps"),
+ "hr_second_pass_steps",
+ 0,
+ localStorage.getItem("openoutpaint/settings.max-steps") || 70,
+ 5,
+ 0,
+ 1
+);
+
+function changeMaskBlur() {
+ stableDiffusionData.mask_blur = parseInt(
+ document.getElementById("maskBlur").value
+ );
+ localStorage.setItem("openoutpaint/mask_blur", stableDiffusionData.mask_blur);
+}
+
+function changeSeed() {
+ stableDiffusionData.seed = document.getElementById("seed").value;
+ localStorage.setItem("openoutpaint/seed", stableDiffusionData.seed);
+}
+
+function changeHRFX() {
+ stableDiffusionData.hr_resize_x =
+ document.getElementById("hr_resize_x").value;
+}
+
+function changeHRFY() {
+ stableDiffusionData.hr_resize_y =
+ document.getElementById("hr_resize_y").value;
+}
+
+function changeDynamicPromptsExtension() {
+ extensions.dynamicPromptsActive =
+ document.getElementById("cbxDynPrompts").checked;
+}
+
+function changeControlNetExtension() {
+ extensions.controlNetActive =
+ document.getElementById("cbxControlNet").checked;
+ if (extensions.controlNetActive) {
+ document
+ .querySelectorAll(".controlNetElement")
+ .forEach((el) => el.classList.remove("invisible"));
+ } else {
+ document
+ .querySelectorAll(".controlNetElement")
+ .forEach((el) => el.classList.add("invisible"));
+ }
+ changeControlNetReference();
+}
+
+function changeControlNetReference() {
+ extensions.controlNetReferenceActive = document.getElementById(
+ "cbxControlNetReferenceLayer"
+ ).checked;
+ if (extensions.controlNetReferenceActive && extensions.controlNetActive) {
+ document
+ .querySelectorAll(".controlNetReferenceElement")
+ .forEach((el) => el.classList.remove("invisible"));
+ } else {
+ document
+ .querySelectorAll(".controlNetReferenceElement")
+ .forEach((el) => el.classList.add("invisible"));
+ }
+}
+
+function changeHiResFix() {
+ stableDiffusionData.enable_hr = Boolean(
+ document.getElementById("cbxHRFix").checked
+ );
+ localStorage.setItem("openoutpaint/enable_hr", stableDiffusionData.enable_hr);
+ // var hrfSlider = document.getElementById("hrFixScale");
+ // var hrfOpotions = document.getElementById("hrFixUpscaler");
+ // var hrfLabel = document.getElementById("hrFixLabel");
+ // var hrfDenoiseSlider = document.getElementById("hrDenoising");
+ // var hrfLockPxSlider = document.getElementById("hrFixLockPx");
+ if (stableDiffusionData.enable_hr) {
+ document
+ .querySelectorAll(".hrfix")
+ .forEach((el) => el.classList.remove("invisible"));
+ } else {
+ document
+ .querySelectorAll(".hrfix")
+ .forEach((el) => el.classList.add("invisible"));
+ }
+}
+
+function changeHiResSquare() {
+ stableDiffusionData.hr_square_aspect = Boolean(
+ document.getElementById("cbxHRFSquare").checked
+ );
+}
+
+function changeRestoreFaces() {
+ stableDiffusionData.restore_faces = Boolean(
+ document.getElementById("cbxRestoreFaces").checked
+ );
+ localStorage.setItem(
+ "openoutpaint/restore_faces",
+ stableDiffusionData.restore_faces
+ );
+}
+
+function changeSyncCursorSize() {
+ global.syncCursorSize = Boolean(
+ document.getElementById("cbxSyncCursorSize").checked
+ );
+ localStorage.setItem("openoutpaint/sync_cursor_size", global.syncCursorSize);
+}
+
+function changeSmoothRendering() {
+ const layers = document.getElementById("layer-render");
+ if (localStorage.getItem("openoutpaint/settings.smooth") === "true") {
+ layers.classList.remove("pixelated");
+ } else {
+ layers.classList.add("pixelated");
+ }
+}
+
+function isCanvasBlank(x, y, w, h, canvas) {
+ return !canvas
+ .getContext("2d")
+ .getImageData(x, y, w, h)
+ .data.some((channel) => channel !== 0);
+}
+
+function drawBackground() {
+ {
+ // Existing Canvas BG
+ const canvas = document.createElement("canvas");
+ canvas.width = config.gridSize * 2;
+ canvas.height = config.gridSize * 2;
+
+ const ctx = canvas.getContext("2d");
+ ctx.fillStyle = theme.grid.dark;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ ctx.fillStyle = theme.grid.light;
+ ctx.fillRect(0, 0, config.gridSize, config.gridSize);
+ ctx.fillRect(
+ config.gridSize,
+ config.gridSize,
+ config.gridSize,
+ config.gridSize
+ );
+
+ canvas.toBlob((blob) => {
+ const url = window.URL.createObjectURL(blob);
+ console.debug(url);
+ bgLayer.canvas.style.backgroundImage = `url(${url})`;
+ });
+ }
+}
+
+async function exportWorkspaceState() {
+ return {
+ defaultLayer: {
+ id: uil.layerIndex.default.id,
+ name: uil.layerIndex.default.name,
+ },
+ bb: {
+ x: imageCollection.bb.x,
+ y: imageCollection.bb.y,
+ w: imageCollection.bb.w,
+ h: imageCollection.bb.h,
+ },
+ history: await commands.export(),
+ };
+}
+
+async function importWorkspaceState(state) {
+ // Start from zero, effectively
+ await commands.clear();
+
+ // Setup initial layer
+ const layer = uil.layerIndex.default;
+ layer.deletable = true;
+
+ await commands.runCommand(
+ "addLayer",
+ "Temporary Layer",
+ {name: "Temporary Layer", key: "tmp"},
+ {recordHistory: false}
+ );
+
+ await commands.runCommand(
+ "deleteLayer",
+ "Deleted Layer",
+ {
+ layer,
+ },
+ {recordHistory: false}
+ );
+
+ await commands.runCommand(
+ "addLayer",
+ "Initial Layer Creation",
+ {
+ id: state.defaultLayer.id,
+ name: state.defaultLayer.name,
+ key: "default",
+ deletable: false,
+ },
+ {recordHistory: false}
+ );
+
+ await commands.runCommand(
+ "deleteLayer",
+ "Deleted Layer",
+ {
+ layer: uil.layerIndex.tmp,
+ },
+ {recordHistory: false}
+ );
+
+ // Resize canvas to match original size
+ const sbb = new BoundingBox(state.bb);
+
+ const bb = imageCollection.bb;
+ let eleft = 0;
+ if (bb.x > sbb.x) eleft = bb.x - sbb.x;
+ let etop = 0;
+ if (bb.y > sbb.y) etop = bb.y - sbb.y;
+
+ let eright = 0;
+ if (bb.tr.x < sbb.tr.x) eright = sbb.tr.x - bb.tr.x;
+ let ebottom = 0;
+ if (bb.br.y < sbb.br.y) ebottom = sbb.br.y - bb.br.y;
+
+ imageCollection.expand(eleft, etop, eright, ebottom);
+
+ // Run commands in order
+ for (const command of state.history) {
+ await commands.import(command);
+ }
+}
+
+async function saveWorkspaceToFile() {
+ const workspace = await exportWorkspaceState();
+
+ const blob = new Blob([JSON.stringify(workspace)], {
+ type: "application/json",
+ });
+
+ const url = URL.createObjectURL(blob);
+ var link = document.createElement("a"); // Or maybe get it from the current document
+ link.href = url;
+ link.download = `${new Date().toISOString()}_openOutpaint_workspace.json`;
+ link.click();
+}
+
+async function getUpscalers() {
+ var url = document.getElementById("host").value + "/sdapi/v1/upscalers";
+ let upscalers = [];
+
+ try {
+ const response = await fetch(url, {
+ method: "GET",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ });
+ const data = await response.json();
+ for (var i = 0; i < data.length; i++) {
+ if (data[i].name.includes("None")) {
+ continue;
+ }
+ upscalers.push(data[i].name);
+ }
+ } catch (e) {
+ console.warn("[index] Failed to fetch upscalers:");
+ console.warn(e);
+ upscalers = [
+ "Lanczos",
+ "Nearest",
+ "LDSR",
+ "SwinIR",
+ "R-ESRGAN General 4xV3",
+ "R-ESRGAN General WDN 4xV3",
+ "R-ESRGAN AnimeVideo",
+ "R-ESRGAN 4x+",
+ "R-ESRGAN 4x+ Anime6B",
+ "R-ESRGAN 2x+",
+ ];
+ }
+ const upscalersPlusNone = [...upscalers];
+ upscalersPlusNone.unshift("None");
+ upscalersPlusNone.push("Latent");
+ upscalersPlusNone.push("Latent (antialiased)");
+ upscalersPlusNone.push("Latent (bicubic)");
+ upscalersPlusNone.push("Latent (bicubic, antialiased)");
+ upscalersPlusNone.push("Latent (nearest)");
+
+ upscalerAutoComplete.options = upscalers.map((u) => {
+ return {name: u, value: u};
+ });
+ hrFixUpscalerAutoComplete.options = upscalersPlusNone.map((u) => {
+ return {name: u, value: u};
+ });
+
+ upscalerAutoComplete.value = upscalers[0];
+ hrFixUpscalerAutoComplete.value =
+ localStorage.getItem("openoutpaint/hr_upscaler") === null
+ ? "None"
+ : localStorage.getItem("openoutpaint/hr_upscaler");
+}
+
+async function getModels(refresh = false) {
+ const url = document.getElementById("host").value + "/sdapi/v1/sd-models";
+ let opt = null;
+
+ try {
+ const response = await fetch(url);
+ const data = await response.json();
+
+ opt = data.map((option) => ({
+ name: option.title,
+ value: option.title,
+ optionelcb: (el) => {
+ if (option.title.toLowerCase().includes("inpainting"))
+ el.classList.add("inpainting");
+ },
+ }));
+
+ modelAutoComplete.options = opt;
+
+ try {
+ const optResponse = await fetch(
+ document.getElementById("host").value + "/sdapi/v1/options"
+ );
+ const optData = await optResponse.json();
+
+ var model = optData.sd_model_checkpoint;
+ // 20230722 - sigh so this key is now removed https://github.com/AUTOMATIC1111/stable-diffusion-webui/commit/66c5f1bb1556a2d86d9f11aeb92f83d4a09832cc
+ // no idea why but time to deal with it
+ if (model === undefined) {
+ const modelHash = optData.sd_checkpoint_hash;
+ const hashMap = data.map((option) => ({
+ hash: option.sha256,
+ title: option.title,
+ }));
+ model = hashMap.find((option) => option.hash === modelHash).title;
+ }
+ console.log("Current model: " + model);
+ if (modelAutoComplete.value !== model) modelAutoComplete.value = model;
+ } catch (e) {
+ console.warn("[index] Failed to fetch current model:");
+ console.warn(e);
+ }
+ } catch (e) {
+ console.warn("[index] Failed to fetch models:");
+ console.warn(e);
+ }
+
+ if (!refresh)
+ modelAutoComplete.onchange.on(async ({value}) => {
+ console.log(`[index] Changing model to [${value}]`);
+ const payload = {
+ sd_model_checkpoint: value,
+ };
+ const url = document.getElementById("host").value + "/sdapi/v1/options/";
+ try {
+ await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+
+ notifications.notify(`Model changed to [${value}]`, {type: "success"});
+ } catch (e) {
+ console.warn("[index] Error changing model");
+ console.warn(e);
+
+ notifications.notify(
+ "Error changing model, please check console for additional information",
+ {
+ type: NotificationType.ERROR,
+ timeout: config.notificationTimeout * 2,
+ }
+ );
+ }
+ });
+
+ // If first time running, ask if user wants to switch to an inpainting model
+ if (global.firstRun && !modelAutoComplete.value.includes("inpainting")) {
+ const inpainting = opt.find(({name}) => name.includes("inpainting"));
+
+ let message =
+ "It seems this is your first time using openOutpaint. It is highly recommended that you switch to an inpainting model. \
+ These are highlighted as green in the model selector.";
+
+ if (inpainting) {
+ message += `
We have found the inpainting model
- ${inpainting.name}
available in the webui. Do you want to switch to it?`;
+ if (await notifications.dialog("Automatic Model Switch", message)) {
+ modelAutoComplete.value = inpainting.value;
+ }
+ } else {
+ message += `
No inpainting model seems to be available in the webui. It is recommended that you download an inpainting model, or outpainting results may not be optimal.`;
+ notifications.notify(message, {
+ type: NotificationType.WARN,
+ timeout: null,
+ });
+ }
+ }
+}
+
+async function getLoras() {
+ var url = document.getElementById("host").value + "/sdapi/v1/loras";
+ let opt = null;
+
+ try {
+ const response = await fetch(url);
+ const data = await response.json();
+
+ loraAutoComplete.options = data.map((lora) => ({
+ name: lora.name,
+ value: lora.name,
+ }));
+ } catch (e) {
+ console.warn("[index] Failed to fetch loras");
+ console.warn(e);
+ }
+}
+
+async function getConfig() {
+ var url = document.getElementById("host").value + "/sdapi/v1/options";
+
+ let message =
+ "The following options for the AUTOMATIC1111's webui are not recommended to use with this software:";
+
+ try {
+ const response = await fetch(url);
+
+ const data = await response.json();
+
+ let wrong = false;
+
+ // Check if img2img color correction is disabled and inpainting mask weight is set to one
+ // TODO: API Seems bugged for retrieving inpainting mask weight - returning 0 for all values different than 1.0
+ if (data.img2img_color_correction) {
+ message += " - Image to Image Color Correction: false recommended";
+ wrong = true;
+ }
+
+ if (data.inpainting_mask_weight < 1.0) {
+ message += ` - Inpainting Conditioning Mask Strength: 1.0 recommended`;
+ wrong = true;
+ }
+
+ message +=
+ "
Should these values be changed to the recommended ones?";
+
+ if (!wrong) {
+ console.info("[index] WebUI Settings set as recommended.");
+ return;
+ }
+
+ console.info(
+ "[index] WebUI Settings not set as recommended. Prompting for changing settings automatically."
+ );
+
+ if (!(await notifications.dialog("Recommended Settings", message))) return;
+
+ try {
+ await fetch(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ img2img_color_correction: false,
+ inpainting_mask_weight: 1.0,
+ }),
+ });
+ } catch (e) {
+ console.warn("[index] Failed to fetch WebUI Configuration");
+ console.warn(e);
+ }
+ } catch (e) {
+ console.warn("[index] Failed to fetch WebUI Configuration");
+ console.warn(e);
+ }
+}
+
+function changeStyles() {
+ /** @type {HTMLSelectElement} */
+ const styleSelectEl = document.getElementById("styleSelect");
+ const selected = Array.from(styleSelectEl.options).filter(
+ (option) => option.selected
+ );
+ let selectedString = selected.map((option) => option.value);
+
+ if (selectedString.find((selected) => selected === "None")) {
+ selectedString = [];
+ Array.from(styleSelectEl.options).forEach((option) => {
+ if (option.value !== "None") option.selected = false;
+ });
+ }
+
+ localStorage.setItem(
+ "openoutpaint/promptStyle",
+ JSON.stringify(selectedString)
+ );
+
+ // change the model
+ if (selectedString.length > 0)
+ console.log(`[index] Changing styles to ${selectedString.join(", ")}`);
+ else console.log(`[index] Clearing styles`);
+ stableDiffusionData.styles = selectedString;
+}
+
+async function getSamplers() {
+ var url = document.getElementById("host").value + "/sdapi/v1/samplers";
+
+ try {
+ const response = await fetch(url);
+ const data = await response.json();
+
+ samplerAutoComplete.onchange.on(({value}) => {
+ stableDiffusionData.sampler_index = value;
+ localStorage.setItem("openoutpaint/sampler", value);
+ });
+
+ samplerAutoComplete.options = data.map((sampler) => ({
+ name: sampler.name,
+ value: sampler.name,
+ }));
+
+ // Initial sampler
+ if (localStorage.getItem("openoutpaint/sampler") != null) {
+ samplerAutoComplete.value = localStorage.getItem("openoutpaint/sampler");
+ } else {
+ samplerAutoComplete.value = data[0].name;
+ localStorage.setItem("openoutpaint/sampler", samplerAutoComplete.value);
+ }
+ stableDiffusionData.sampler_index = samplerAutoComplete.value;
+ } catch (e) {
+ console.warn("[index] Failed to fetch samplers");
+ console.warn(e);
+ }
+}
+
+async function upscaleAndDownload() {
+ // Future improvements: some upscalers take a while to upscale, so we should show a loading bar or something, also a slider for the upscale amount
+
+ // get cropped canvas, send it to upscaler, download result
+ var upscale_factor = localStorage.getItem("openoutpaint/upscale_x")
+ ? localStorage.getItem("openoutpaint/upscale_x")
+ : 2;
+ var upscaler = upscalerAutoComplete.value;
+ var croppedCanvas = cropCanvas(
+ uil.getVisible({
+ x: 0,
+ y: 0,
+ w: imageCollection.size.w,
+ h: imageCollection.size.h,
+ })
+ );
+ if (croppedCanvas != null) {
+ var url =
+ document.getElementById("host").value + "/sdapi/v1/extra-single-image/";
+ var imgdata = croppedCanvas.canvas.toDataURL("image/png");
+ var data = {
+ "resize-mode": 0, // 0 = just resize, 1 = crop and resize, 2 = resize and fill i assume based on theimg2img tabs options
+ upscaling_resize: upscale_factor,
+ upscaler_1: upscaler,
+ image: imgdata,
+ };
+ console.log(data);
+ await fetch(url, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(data),
+ })
+ .then((response) => response.json())
+ .then((data) => {
+ console.log(data);
+ var link = document.createElement("a");
+ link.download =
+ new Date()
+ .toISOString()
+ .slice(0, 19)
+ .replace("T", " ")
+ .replace(":", " ") +
+ " openOutpaint image upscaler_" +
+ upscaler +
+ "_x" +
+ upscale_factor +
+ ".png";
+ link.href = "data:image/png;base64," + data["image"];
+ link.click();
+ });
+ }
+}
+
+function loadSettings() {
+ // set default values if not set
+ var _mask_blur =
+ localStorage.getItem("openoutpaint/mask_blur") == null
+ ? 0
+ : localStorage.getItem("openoutpaint/mask_blur");
+ var _seed =
+ localStorage.getItem("openoutpaint/seed") == null
+ ? -1
+ : localStorage.getItem("openoutpaint/seed");
+ let _enable_hr =
+ localStorage.getItem("openoutpaint/enable_hr") === null
+ ? false
+ : localStorage.getItem("openoutpaint/enable_hr") === "true";
+ let _restore_faces =
+ localStorage.getItem("openoutpaint/restore_faces") === null
+ ? false
+ : localStorage.getItem("openoutpaint/restore_faces") === "true";
+
+ let _sync_cursor_size =
+ localStorage.getItem("openoutpaint/sync_cursor_size") === null
+ ? true
+ : localStorage.getItem("openoutpaint/sync_cursor_size") === "true";
+
+ let _hrfix_scale =
+ localStorage.getItem("openoutpaint/hr_scale") === null
+ ? 2.0
+ : localStorage.getItem("openoutpaint/hr_scale");
+
+ let _hrfix_denoising =
+ localStorage.getItem("openoutpaint/hr_denoising_strength") === null
+ ? 0.7
+ : localStorage.getItem("openoutpaint/hr_denoising_strength");
+ let _hrfix_lock_px =
+ localStorage.getItem("openoutpaint/hr_fix_lock_px") === null
+ ? 0
+ : localStorage.getItem("openoutpaint/hr_fix_lock_px");
+
+ // set the values into the UI
+ document.getElementById("maskBlur").value = Number(_mask_blur);
+ document.getElementById("seed").value = Number(_seed);
+ document.getElementById("cbxHRFix").checked = Boolean(_enable_hr);
+ document.getElementById("cbxRestoreFaces").checked = Boolean(_restore_faces);
+ document.getElementById("cbxSyncCursorSize").checked =
+ Boolean(_sync_cursor_size);
+ document.getElementById("hrFixScale").value = Number(_hrfix_scale);
+ document.getElementById("hrDenoising").value = Number(_hrfix_denoising);
+ document.getElementById("hrFixLockPx").value = Number(_hrfix_lock_px);
+}
+
+imageCollection.element.addEventListener(
+ "wheel",
+ (evn) => {
+ evn.preventDefault();
+ },
+ {passive: false}
+);
+
+imageCollection.element.addEventListener(
+ "contextmenu",
+ (evn) => {
+ evn.preventDefault();
+ },
+ {passive: false}
+);
+
+async function resetToDefaults() {
+ if (
+ await notifications.dialog(
+ "Clear Settings",
+ "Are you sure you want to clear your settings?"
+ )
+ ) {
+ localStorage.clear();
+ }
+}
+
+document.addEventListener("visibilitychange", () => {
+ checkFocus();
+});
+
+window.addEventListener("blur", () => {
+ checkFocus();
+});
+
+window.addEventListener("focus", () => {
+ checkFocus();
+});
+
+function checkFocus() {
+ let hasFocus = document.hasFocus();
+ if (document.hidden || !hasFocus) {
+ focused = false;
+ } else {
+ focused = true;
+ }
+}
+
+function refreshScripts() {
+ selector = document.getElementById("script-selector");
+ selector.innerHTML = "";
+ createBaseScriptOptions();
+ loadDefaultScripts();
+ // loadUserScripts();
+}
+
+function createBaseScriptOptions() {
+ selector = document.getElementById("script-selector");
+ var noScript = document.createElement("option");
+ noScript.id = "no_selected_script";
+ noScript.value = "";
+ noScript.innerHTML = "(disabled)";
+ selector.appendChild(noScript);
+ var customScript = document.createElement("option");
+ customScript.id = "custom";
+ customScript.value = "custom";
+ customScript.innerHTML = "Custom";
+ selector.appendChild(customScript);
+}
+
+async function loadDefaultScripts() {
+ selector = document.getElementById("script-selector");
+ const response = await fetch("./defaultscripts.json");
+ const json = await response.json();
+ for (const key in json.defaultScripts) {
+ var opt = document.createElement("option");
+ opt.value = opt.innerHTML = key;
+ selector.appendChild(opt);
+ }
+ defaultScripts = json;
+}
+
+// async function loadUserScripts() {
+// selector = document.getElementById("script-selector");
+// const response = await fetch("./userdefinedscripts.json");
+// const json = await response.json();
+// for (const key in json.userScripts) {
+// var opt = document.createElement("option");
+// opt.value = opt.innerHTML = key;
+// selector.appendChild(opt);
+// }
+// userScripts = json;
+// }
+
+function changeScript(event) {
+ let enable = () => {
+ scriptName.disabled = false;
+ // saveScriptButton.disabled = false;
+ };
+ let disable = () => {
+ scriptName.disabled = true;
+ // saveScriptButton.disabled = true;
+ };
+ let selected = event.target.value;
+ let scriptName = document.getElementById("script-name-input");
+ let scriptArgs = document.getElementById("script-args-input");
+ // let saveScriptButton = document.getElementById("save-custom-script");
+ scriptName.value = selected;
+ scriptArgs.title = "";
+ disable();
+ switch (selected) {
+ case "custom": {
+ let _script_name_input =
+ localStorage.getItem("openoutpaint/script_name_input") === null
+ ? ""
+ : localStorage.getItem("openoutpaint/script_name_input");
+ let _script_args_input =
+ localStorage.getItem("openoutpaint/script_args_input") === null
+ ? "[]"
+ : localStorage.getItem("openoutpaint/script_args_input");
+ scriptName.value = _script_name_input;
+ scriptArgs.value = _script_args_input;
+ scriptArgs.title = "";
+ enable();
+ break;
+ }
+ case "": {
+ // specifically no selected script
+ scriptArgs.value = "";
+ break;
+ }
+ default: {
+ scriptName.value = selected;
+ // check default scripts for selected script
+ if (selected in defaultScripts.defaultScripts) {
+ scriptArgs.value = defaultScripts.defaultScripts[selected].scriptValues;
+ scriptArgs.title = defaultScripts.defaultScripts[selected].titleText;
+ }
+ // FURTHER TODO see if this is even plausible as i don't think JS can arbitrarily save data to files without downloading
+
+ // if not found, check user scripts
+ // TODO yknow what check userscripts first; if customizations have been made load those instead of defaults, i'm too stupid to do that right now
+ // else if (selected in userScripts.userScripts) {
+ // scriptArgs.value = userScripts.userScripts[selected].scriptValues;
+ // enable();
+ // }
+ // if not found, wtf
+ }
+ }
+}
+
+// async function saveCustomScript() {
+// let selector = document.getElementById("script-name-input");
+// let selected = selector.value;
+// let args = document.getElementById("script-args-input").value;
+// if (selected.trim() != "") {
+// if (selected in userScripts.userScripts) {
+// userScripts.userScripts[selected].scriptValues = args;
+// } else {
+// }
+// }
+// }
+
+function togglePix2PixImgCfg(value) {
+ // super hacky
+ // actually doesn't work at all yet so i'm leaving it here to taunt and remind me of my failures
+ // try {
+ // if (value.toLowerCase().includes("pix2pix")) {
+ // document
+ // .querySelector(".instruct-pix2pix-img-cfg")
+ // .classList.remove("invisible");
+ // } else {
+ // document
+ // .querySelector(".instruct-pix2pix-img-cfg")
+ // .classList.add("invisible");
+ // }
+ // } catch (e) {
+ // // highly likely not currently using img2img tool, do nothing
+ // }
+}
+
+function storeUserscriptVal(evt, type) {
+ let currentScript = document.getElementById("script-selector").value;
+ if (currentScript === "custom") {
+ console.log(type);
+ console.log(evt);
+ let val = (currentScript = document.getElementById(
+ "script-" + type + "-input"
+ ).value);
+
+ localStorage.setItem("openoutpaint/script_" + type + "_input", val);
+ }
+}
diff --git a/openOutpaint-webUI-extension/app/js/initalize/debug.populate.js b/openOutpaint-webUI-extension/app/js/initalize/debug.populate.js
new file mode 100644
index 0000000000000000000000000000000000000000..0843bf70c8819f01cd49c0878ba43f657e9e892c
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/initalize/debug.populate.js
@@ -0,0 +1,33 @@
+// info div, sometimes hidden
+let mouseXInfo = document.getElementById("mouseX");
+let mouseYInfo = document.getElementById("mouseY");
+let canvasXInfo = document.getElementById("canvasX");
+let canvasYInfo = document.getElementById("canvasY");
+let snapXInfo = document.getElementById("snapX");
+let snapYInfo = document.getElementById("snapY");
+let heldButtonInfo = document.getElementById("heldButton");
+
+mouse.listen.window.onmousemove.on((evn) => {
+ mouseXInfo.textContent = evn.x;
+ mouseYInfo.textContent = evn.y;
+});
+
+mouse.listen.world.onmousemove.on((evn) => {
+ canvasXInfo.textContent = evn.x;
+ canvasYInfo.textContent = evn.y;
+ snapXInfo.textContent = evn.x + snap(evn.x);
+ snapYInfo.textContent = evn.y + snap(evn.y);
+
+ if (global.debug) {
+ debugLayer.clear();
+ debugCtx.fillStyle = "#F0F";
+ debugCtx.beginPath();
+ debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2);
+ debugCtx.fill();
+
+ debugCtx.fillStyle = "#0FF";
+ debugCtx.beginPath();
+ debugCtx.arc(evn.x, evn.y, 5, 0, Math.PI * 2);
+ debugCtx.fill();
+ }
+});
diff --git a/openOutpaint-webUI-extension/app/js/initalize/layers.populate.js b/openOutpaint-webUI-extension/app/js/initalize/layers.populate.js
new file mode 100644
index 0000000000000000000000000000000000000000..5235f5ebfb8f260543268385228a3af6442166f7
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/initalize/layers.populate.js
@@ -0,0 +1,387 @@
+// Layering
+const imageCollection = layers.registerCollection(
+ "image",
+ {
+ w: parseInt(
+ (localStorage &&
+ localStorage.getItem("openoutpaint/settings.canvas-width")) ||
+ 2048
+ ),
+ h: parseInt(
+ (localStorage &&
+ localStorage.getItem("openoutpaint/settings.canvas-height")) ||
+ 2048
+ ),
+ },
+ {
+ name: "Image Layers",
+ }
+);
+
+const bgLayer = imageCollection.registerLayer("bg", {
+ name: "Background",
+ category: "background",
+});
+
+bgLayer.canvas.classList.add("pixelated");
+
+const imgLayer = imageCollection.registerLayer("image", {
+ name: "Image",
+ category: "image",
+ ctxOptions: {desynchronized: true},
+});
+const maskPaintLayer = imageCollection.registerLayer("mask", {
+ name: "Mask Paint",
+ category: "mask",
+ ctxOptions: {desynchronized: true},
+});
+const ovLayer = imageCollection.registerLayer("overlay", {
+ name: "Overlay",
+ category: "display",
+});
+const debugLayer = imageCollection.registerLayer("debug", {
+ name: "Debug Layer",
+ category: "display",
+});
+
+const imgCanvas = imgLayer.canvas; // where dreams go
+const imgCtx = imgLayer.ctx;
+
+const maskPaintCanvas = maskPaintLayer.canvas; // where mouse cursor renders
+const maskPaintCtx = maskPaintLayer.ctx;
+
+maskPaintCanvas.classList.add("mask-canvas");
+
+const ovCanvas = ovLayer.canvas; // where mouse cursor renders
+const ovCtx = ovLayer.ctx;
+
+const debugCanvas = debugLayer.canvas; // where mouse cursor renders
+const debugCtx = debugLayer.ctx;
+
+/* WIP: Most cursors shouldn't need a zoomable canvas */
+/** @type {HTMLCanvasElement} */
+const uiCanvas = document.getElementById("layer-overlay"); // where mouse cursor renders
+uiCanvas.width = uiCanvas.clientWidth;
+uiCanvas.height = uiCanvas.clientHeight;
+const uiCtx = uiCanvas.getContext("2d", {desynchronized: true});
+
+/**
+ * Here we setup canvas dynamic scaling
+ */
+(() => {
+ let expandSize = localStorage.getItem("openoutpaint/expand-size") || 1024;
+ expandSize = parseInt(expandSize, 10);
+
+ const askSize = (e) => {
+ if (e.ctrlKey) return expandSize;
+ const by = prompt("How much do you want to expand by?", expandSize);
+
+ if (!by) return null;
+ else {
+ const len = parseInt(by, 10);
+ localStorage.setItem("openoutpaint/expand-size", len);
+ expandSize = len;
+ return len;
+ }
+ };
+
+ const leftButton = makeElement("button", -64, 0);
+ leftButton.classList.add("expand-button", "left");
+ leftButton.style.width = "64px";
+ leftButton.style.height = `${imageCollection.size.h}px`;
+ leftButton.addEventListener("click", (e) => {
+ let size = null;
+ if ((size = askSize(e))) {
+ imageCollection.expand(size, 0, 0, 0);
+ bgLayer.canvas.style.backgroundPosition = `${-snap(
+ imageCollection.origin.x,
+ 0,
+ config.gridSize * 2
+ )}px ${-snap(imageCollection.origin.y, 0, config.gridSize * 2)}px`;
+ const newLeft = -imageCollection.inputOffset.x - imageCollection.origin.x;
+ leftButton.style.left = newLeft - 64 + "px";
+ topButton.style.left = newLeft + "px";
+ bottomButton.style.left = newLeft + "px";
+ topButton.style.width = imageCollection.size.w + "px";
+ bottomButton.style.width = imageCollection.size.w + "px";
+ }
+ });
+
+ const rightButton = makeElement("button", imageCollection.size.w, 0);
+ rightButton.classList.add("expand-button", "right");
+ rightButton.style.width = "64px";
+ rightButton.style.height = `${imageCollection.size.h}px`;
+ rightButton.addEventListener("click", (e) => {
+ let size = null;
+ if ((size = askSize(e))) {
+ imageCollection.expand(0, 0, size, 0);
+ rightButton.style.left =
+ parseInt(rightButton.style.left, 10) + size + "px";
+ topButton.style.width = imageCollection.size.w + "px";
+ bottomButton.style.width = imageCollection.size.w + "px";
+ }
+ });
+
+ const topButton = makeElement("button", 0, -64);
+ topButton.classList.add("expand-button", "top");
+ topButton.style.height = "64px";
+ topButton.style.width = `${imageCollection.size.w}px`;
+ topButton.addEventListener("click", (e) => {
+ let size = null;
+ if ((size = askSize(e))) {
+ imageCollection.expand(0, size, 0, 0);
+ bgLayer.canvas.style.backgroundPosition = `${-snap(
+ imageCollection.origin.x,
+ 0,
+ config.gridSize * 2
+ )}px ${-snap(imageCollection.origin.y, 0, config.gridSize * 2)}px`;
+ const newTop = -imageCollection.inputOffset.y - imageCollection.origin.y;
+ topButton.style.top = newTop - 64 + "px";
+ leftButton.style.top = newTop + "px";
+ rightButton.style.top = newTop + "px";
+ leftButton.style.height = imageCollection.size.h + "px";
+ rightButton.style.height = imageCollection.size.h + "px";
+ }
+ });
+
+ const bottomButton = makeElement("button", 0, imageCollection.size.h);
+ bottomButton.classList.add("expand-button", "bottom");
+ bottomButton.style.height = "64px";
+ bottomButton.style.width = `${imageCollection.size.w}px`;
+ bottomButton.addEventListener("click", (e) => {
+ let size = null;
+ if ((size = askSize(e))) {
+ imageCollection.expand(0, 0, 0, size);
+ bottomButton.style.top =
+ parseInt(bottomButton.style.top, 10) + size + "px";
+ leftButton.style.height = imageCollection.size.h + "px";
+ rightButton.style.height = imageCollection.size.h + "px";
+ }
+ });
+})();
+
+debugLayer.hide(); // Hidden by default
+
+// Where CSS and javascript magic happens to make the canvas viewport work
+/**
+ * The global viewport object (may be modularized in the future). All
+ * coordinates given are of the center of the viewport
+ *
+ * cx and cy are the viewport's world coordinates.
+ *
+ * The transform() function does some transforms and writes them to the
+ * provided element.
+ */
+class Viewport {
+ cx = 0;
+ cy = 0;
+
+ zoom = 1;
+
+ /**
+ * Gets viewport width in canvas coordinates
+ */
+ get w() {
+ return window.innerWidth * this.zoom;
+ }
+
+ /**
+ * Gets viewport height in canvas coordinates
+ */
+ get h() {
+ return window.innerHeight * this.zoom;
+ }
+
+ constructor(x, y) {
+ this.x = x;
+ this.y = y;
+ }
+
+ get v2c() {
+ const m = new DOMMatrix();
+
+ m.translateSelf(-this.w / 2, -this.h / 2);
+ m.translateSelf(this.cx, this.cy);
+ m.scaleSelf(this.zoom);
+
+ return m;
+ }
+
+ get c2v() {
+ return this.v2c.invertSelf();
+ }
+
+ viewToCanvas(x, y) {
+ if (x.x !== undefined) return this.v2c.transformPoint(x);
+ return this.v2c.transformPoint({x, y});
+ }
+
+ canvasToView(x, y) {
+ if (x.x !== undefined) return this.c2v.transformPoint(x);
+ return this.c2v.transformPoint({x, y});
+ }
+
+ /**
+ * Apply transformation
+ *
+ * @param {HTMLElement} el Element to apply CSS transform to
+ */
+ transform(el) {
+ el.style.transformOrigin = "0px 0px";
+ el.style.transform = this.c2v;
+ }
+}
+
+const viewport = new Viewport(0, 0);
+
+viewport.cx = imageCollection.size.w / 2;
+viewport.cy = imageCollection.size.h / 2;
+
+let worldInit = null;
+
+viewport.transform(imageCollection.element);
+
+/**
+ * Ended up using a CSS transforms approach due to more flexibility on transformations
+ * and capability to automagically translate input coordinates to layer space.
+ */
+mouse.registerContext(
+ "world",
+ (evn, ctx) => {
+ // Fix because in chrome layerX and layerY simply doesnt work
+ ctx.coords.prev.x = ctx.coords.pos.x;
+ ctx.coords.prev.y = ctx.coords.pos.y;
+
+ // Get cursor position
+ const x = evn.clientX;
+ const y = evn.clientY;
+
+ // Map to layer space
+ const layerCoords = viewport.viewToCanvas(x, y);
+
+ // Set coords
+ ctx.coords.pos.x = Math.round(layerCoords.x);
+ ctx.coords.pos.y = Math.round(layerCoords.y);
+ },
+ {
+ target: imageCollection.inputElement,
+ validate: (evn) => {
+ if ((!global.hasActiveInput && !evn.ctrlKey) || evn.type === "mousemove")
+ return true;
+ return false;
+ },
+ }
+);
+
+mouse.registerContext(
+ "camera",
+ (evn, ctx) => {
+ ctx.coords.prev.x = ctx.coords.pos.x;
+ ctx.coords.prev.y = ctx.coords.pos.y;
+
+ // Set coords
+ ctx.coords.pos.x = evn.x;
+ ctx.coords.pos.y = evn.y;
+ },
+ {
+ validate: (evn) => {
+ return !!evn.ctrlKey;
+ },
+ }
+);
+
+// Redraw on active input state change
+(() => {
+ mouse.listen.window.onany.on((evn) => {
+ const activeInput = DOM.hasActiveInput();
+ if (global.hasActiveInput !== activeInput) {
+ global.hasActiveInput = activeInput;
+ toolbar.currentTool &&
+ toolbar.currentTool.state.redraw &&
+ toolbar.currentTool.state.redraw();
+ }
+ });
+})();
+
+mouse.listen.camera.onwheel.on((evn) => {
+ evn.evn.preventDefault();
+
+ // Get cursor world position
+ const wcursor = viewport.viewToCanvas(evn.x, evn.y);
+
+ // Get viewport center
+ const wcx = viewport.cx;
+ const wcy = viewport.cy;
+
+ // Apply zoom
+ viewport.zoom *= 1 + evn.delta * 0.0002;
+
+ // Get cursor new world position
+ const nwcursor = viewport.viewToCanvas(evn.x, evn.y);
+
+ // Apply normal zoom (center of viewport)
+ viewport.cx = wcx;
+ viewport.cy = wcy;
+
+ // Move viewport to keep cursor in same location
+ viewport.cx += wcursor.x - nwcursor.x;
+ viewport.cy += wcursor.y - nwcursor.y;
+
+ viewport.transform(imageCollection.element);
+
+ toolbar._current_tool.redrawui && toolbar._current_tool.redrawui();
+});
+
+const cameraPaintStart = (evn) => {
+ worldInit = {x: viewport.cx, y: viewport.cy};
+};
+
+const cameraPaint = (evn) => {
+ if (worldInit) {
+ viewport.cx = worldInit.x + (evn.ix - evn.x) * viewport.zoom;
+ viewport.cy = worldInit.y + (evn.iy - evn.y) * viewport.zoom;
+
+ // Limits
+ viewport.cx = Math.max(
+ Math.min(viewport.cx, imageCollection.size.w - imageCollection.origin.x),
+ -imageCollection.origin.x
+ );
+ viewport.cy = Math.max(
+ Math.min(viewport.cy, imageCollection.size.h - imageCollection.origin.y),
+ -imageCollection.origin.y
+ );
+
+ // Draw Viewport location
+ }
+
+ viewport.transform(imageCollection.element);
+ toolbar._current_tool.state.redrawui &&
+ toolbar._current_tool.state.redrawui();
+
+ if (global.debug) {
+ debugCtx.clearRect(0, 0, debugCanvas.width, debugCanvas.height);
+ debugCtx.fillStyle = "#F0F";
+ debugCtx.beginPath();
+ debugCtx.arc(viewport.cx, viewport.cy, 5, 0, Math.PI * 2);
+ debugCtx.fill();
+ }
+};
+
+const cameraPaintEnd = (evn) => {
+ worldInit = null;
+};
+
+mouse.listen.camera.btn.middle.onpaintstart.on(cameraPaintStart);
+mouse.listen.camera.btn.left.onpaintstart.on(cameraPaintStart);
+
+mouse.listen.camera.btn.middle.onpaint.on(cameraPaint);
+mouse.listen.camera.btn.left.onpaint.on(cameraPaint);
+
+mouse.listen.window.btn.middle.onpaintend.on(cameraPaintEnd);
+mouse.listen.window.btn.left.onpaintend.on(cameraPaintEnd);
+
+window.addEventListener("resize", () => {
+ viewport.transform(imageCollection.element);
+ uiCanvas.width = uiCanvas.clientWidth;
+ uiCanvas.height = uiCanvas.clientHeight;
+});
diff --git a/openOutpaint-webUI-extension/app/js/initalize/shortcuts.populate.js b/openOutpaint-webUI-extension/app/js/initalize/shortcuts.populate.js
new file mode 100644
index 0000000000000000000000000000000000000000..357b121da5049f6eefdf7ddd2ad8d54dd3651f11
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/initalize/shortcuts.populate.js
@@ -0,0 +1,39 @@
+// Listen for shortcuts
+keyboard.onShortcut({ctrl: true, key: "KeyZ"}, () => {
+ commands.undo();
+});
+
+keyboard.onShortcut({ctrl: true, key: "KeyY"}, () => {
+ commands.redo();
+});
+
+// Tool shortcuts
+keyboard.onShortcut({key: "KeyD"}, () => {
+ tools.dream.enable();
+});
+keyboard.onShortcut({key: "KeyM"}, () => {
+ tools.maskbrush.enable();
+});
+keyboard.onShortcut({key: "KeyC"}, () => {
+ tools.colorbrush.enable();
+});
+keyboard.onShortcut({key: "KeyI"}, () => {
+ tools.img2img.enable();
+});
+keyboard.onShortcut({key: "KeyS"}, () => {
+ tools.selecttransform.enable();
+});
+keyboard.onShortcut({key: "KeyU"}, () => {
+ tools.stamp.enable();
+});
+keyboard.onShortcut({key: "KeyN"}, () => {
+ tools.interrogate.enable();
+});
+keyboard.onShortcut({key: "Backquote"}, () => {
+ var hax0r = document.getElementById("ui-script");
+ if (hax0r.style.display === "none") {
+ hax0r.style.display = "block";
+ } else {
+ hax0r.style.display = "none";
+ }
+});
diff --git a/openOutpaint-webUI-extension/app/js/initalize/toolbar.populate.js b/openOutpaint-webUI-extension/app/js/initalize/toolbar.populate.js
new file mode 100644
index 0000000000000000000000000000000000000000..07249c83e7463c485d09344246f81d60748ee4de
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/initalize/toolbar.populate.js
@@ -0,0 +1,33 @@
+const tools = {};
+
+/**
+ * Dream tool
+ */
+tools.dream = dreamTool();
+tools.img2img = img2imgTool();
+
+/**
+ * Mask Editing tools
+ */
+toolbar.addSeparator();
+
+/**
+ * Mask Brush tool
+ */
+tools.maskbrush = maskBrushTool();
+tools.colorbrush = colorBrushTool();
+
+/**
+ * Image Editing tools
+ */
+toolbar.addSeparator();
+
+tools.selecttransform = selectTransformTool();
+tools.stamp = stampTool();
+
+/**
+ * Interrogator tool
+ */
+toolbar.addSeparator();
+tools.interrogate = interrogateTool();
+toolbar.tools[0].enable();
diff --git a/openOutpaint-webUI-extension/app/js/initalize/ui.populate.js b/openOutpaint-webUI-extension/app/js/initalize/ui.populate.js
new file mode 100644
index 0000000000000000000000000000000000000000..95a7f80db80c308f2670db1eaff2439197c3ab4e
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/initalize/ui.populate.js
@@ -0,0 +1,88 @@
+/**
+ * Floating window setup
+ */
+document.querySelectorAll(".floating-window").forEach(
+ /**
+ * Runs for each floating window
+ *
+ * @param {HTMLDivElement} w
+ */
+ (w) => {
+ makeDraggable(w);
+ w.addEventListener(
+ "wheel",
+ (e) => {
+ e.stopPropagation();
+ },
+ {passive: false}
+ );
+
+ w.addEventListener(
+ "click",
+ (e) => {
+ e.stopPropagation();
+ },
+ {passive: false}
+ );
+ }
+);
+
+/**
+ * Collapsible element setup
+ */
+var coll = document.getElementsByClassName("collapsible");
+for (var i = 0; i < coll.length; i++) {
+ let active = false;
+ coll[i].addEventListener("click", function () {
+ var content = this.nextElementSibling;
+
+ if (!active) {
+ this.classList.add("active");
+ content.classList.add("active");
+ } else {
+ this.classList.remove("active");
+ content.classList.remove("active");
+ }
+
+ const observer = new ResizeObserver(() => {
+ if (active) content.style.maxHeight = content.scrollHeight + "px";
+ });
+
+ Array.from(content.querySelectorAll("*")).forEach((child) => {
+ observer.observe(child);
+ });
+
+ if (active) {
+ content.style.maxHeight = null;
+ active = false;
+ } else {
+ content.style.maxHeight = content.scrollHeight + "px";
+ active = true;
+ }
+ });
+}
+
+/**
+ * Prompt history setup
+ */
+const _promptHistoryEl = document.getElementById("prompt-history");
+const _promptHistoryBtn = document.getElementById("prompt-history-btn");
+
+_promptHistoryEl.addEventListener("mouseleave", () => {
+ _promptHistoryEl.classList.remove("expanded");
+});
+
+_promptHistoryBtn.addEventListener("click", () =>
+ _promptHistoryEl.classList.toggle("expanded")
+);
+
+/**
+ * Settings overlay setup
+ */
+document.getElementById("settings-btn").addEventListener("click", () => {
+ document.getElementById("page-overlay-wrapper").classList.toggle("invisible");
+});
+
+document.getElementById("settings-btn-close").addEventListener("click", () => {
+ document.getElementById("page-overlay-wrapper").classList.toggle("invisible");
+});
diff --git a/openOutpaint-webUI-extension/app/js/initalize/workspace.populate.js b/openOutpaint-webUI-extension/app/js/initalize/workspace.populate.js
new file mode 100644
index 0000000000000000000000000000000000000000..f3faba3f1f4021ffabadc77ba165dc5c240aa327
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/initalize/workspace.populate.js
@@ -0,0 +1,193 @@
+(() => {
+ const saveWorkspaceBtn = document.getElementById("save-workspace-btn");
+ const renameWorkspaceBtn = document.getElementById("rename-workspace-btn");
+ const moreWorkspaceBtn = document.getElementById("more-workspace-btn");
+ const expandedWorkspaceMenu = document.getElementById("more-workspace-menu");
+ const exportWorkspaceBtn = document.getElementById("export-workspace-btn");
+ const importWorkspaceBtn = document.getElementById("import-workspace-btn");
+ const deleteWorkspaceBtn = document.getElementById("delete-workspace-btn");
+
+ moreWorkspaceBtn.addEventListener("click", () => {
+ expandedWorkspaceMenu.classList.toggle("collapsed");
+ });
+
+ const workspaceAutocomplete = createAutoComplete(
+ "Workspace",
+ document.getElementById("workspace-select")
+ );
+
+ workspaceAutocomplete.options = [{name: "Default", value: "default"}];
+ workspaceAutocomplete.value = "default";
+ renameWorkspaceBtn.disabled = true;
+ deleteWorkspaceBtn.disabled = true;
+
+ workspaceAutocomplete.onchange.on(async ({name, value}) => {
+ if (value === "default") {
+ renameWorkspaceBtn.disabled = true;
+ deleteWorkspaceBtn.disabled = true;
+ await commands.clear();
+ return;
+ }
+ renameWorkspaceBtn.disabled = false;
+ deleteWorkspaceBtn.disabled = false;
+
+ const workspaces = db
+ .transaction("workspaces", "readonly")
+ .objectStore("workspaces");
+
+ workspaces.get(value).onsuccess = (e) => {
+ console.debug("[workspace.populate] Loading workspace");
+
+ const res = e.target.result;
+ const {name, workspace} = res;
+ importWorkspaceState(workspace);
+ notifications.notify(`Loaded workspace '${name}'`, {
+ type: NotificationType.SUCCESS,
+ });
+ };
+ });
+
+ /**
+ * Updates Workspace selection list
+ */
+ const listWorkspaces = async (value = undefined) => {
+ const options = [{name: "Default", value: "default"}];
+
+ const workspaces = db
+ .transaction("workspaces", "readonly")
+ .objectStore("workspaces");
+
+ workspaces.openCursor().onsuccess = (e) => {
+ /** @type {IDBCursor} */
+ const c = e.target.result;
+ if (c) {
+ options.push({name: c.value.name, value: c.key});
+ c.continue();
+ } else {
+ const previousValue = workspaceAutocomplete.value;
+
+ workspaceAutocomplete.options = options;
+ workspaceAutocomplete.value = value ?? previousValue;
+ }
+ };
+ };
+
+ const saveWorkspaceToDB = async (value) => {
+ const workspace = await exportWorkspaceState();
+
+ const workspaces = db
+ .transaction("workspaces", "readwrite")
+ .objectStore("workspaces");
+
+ let id = value;
+ if (value === "default" && commands._history.length > 0) {
+ // If Workspace is the Default
+ const name = (prompt("Please enter the workspace name") ?? "").trim();
+
+ if (name) {
+ id = guid();
+ workspaces.add({id, name, workspace}).onsuccess = () => {
+ listWorkspaces(id);
+ notifications.notify(`Workspace saved as '${name}'`, {
+ type: "success",
+ });
+ };
+ }
+ } else {
+ workspaces.get(id).onsuccess = (e) => {
+ const ws = e.target.result;
+ if (ws) {
+ var name = ws.name;
+ workspaces.delete(id).onsuccess = () => {
+ workspaces.add({id, name, workspace}).onsuccess = () => {
+ notifications.notify(`Workspace saved as '${name}'`, {
+ type: "success",
+ });
+ }; //workspaces.put is failing, delete and re-add?
+ listWorkspaces();
+ };
+ }
+ };
+ }
+ };
+
+ // Normal Workspace Export/Import
+ exportWorkspaceBtn.addEventListener("click", () => saveWorkspaceToFile());
+ importWorkspaceBtn.addEventListener("click", () => {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = "application/json";
+ input.addEventListener("change", async (evn) => {
+ let files = Array.from(input.files);
+ const json = await files[0].text();
+
+ await importWorkspaceState(JSON.parse(json));
+ saveWorkspaceToDB("default");
+ });
+ input.click();
+ });
+
+ const onDatabaseLoad = async () => {
+ // Get workspaces from database
+ listWorkspaces();
+
+ // Save Workspace Button
+ saveWorkspaceBtn.addEventListener("click", () =>
+ saveWorkspaceToDB(workspaceAutocomplete.value)
+ );
+
+ // Rename Workspace
+ renameWorkspaceBtn.addEventListener("click", () => {
+ const workspaces = db
+ .transaction("workspaces", "readwrite")
+ .objectStore("workspaces");
+
+ let id = workspaceAutocomplete.value;
+
+ workspaces.get(id).onsuccess = (e) => {
+ const workspace = e.target.result;
+ const name = prompt(
+ `Please enter the new workspace name. Original is '${workspace.name}'`
+ ).trim();
+
+ if (!name) return;
+
+ workspace.name = name;
+
+ workspaces.put(workspace).onsuccess = () => {
+ notifications.notify(
+ `Workspace name was updated to '${workspace.name}'`,
+ {type: NotificationType.SUCCESS}
+ );
+ listWorkspaces();
+ };
+ };
+ });
+ // Delete Workspace
+ deleteWorkspaceBtn.addEventListener("click", () => {
+ const workspaces = db
+ .transaction("workspaces", "readwrite")
+ .objectStore("workspaces");
+
+ let id = workspaceAutocomplete.value;
+
+ workspaces.get(id).onsuccess = async (e) => {
+ const workspace = e.target.result;
+
+ if (
+ await notifications.dialog(
+ "Delete Workspace",
+ `Do you really want to delete the workspace '${workspace.name}'?`
+ )
+ ) {
+ workspaces.delete(id).onsuccess = (e) => {
+ listWorkspaces("default");
+ };
+ }
+ };
+ });
+ };
+
+ if (db) onDatabaseLoad();
+ else ondatabaseload.on(onDatabaseLoad);
+})();
diff --git a/openOutpaint-webUI-extension/app/js/jsconfig.json b/openOutpaint-webUI-extension/app/js/jsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..802b39e3df930f166f6db7e353b6d92d98e88316
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/jsconfig.json
@@ -0,0 +1,7 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "es6"
+ },
+ "include": ["**/*.js"]
+}
diff --git a/openOutpaint-webUI-extension/app/js/lib/commands.d.js b/openOutpaint-webUI-extension/app/js/lib/commands.d.js
new file mode 100644
index 0000000000000000000000000000000000000000..b69ea8c04b327b718256a90a593db4ae6efc3b92
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/lib/commands.d.js
@@ -0,0 +1,50 @@
+/**
+ * An object that represents an entry of the command in the history
+ *
+ * @typedef CommandEntry
+ * @property {string} id A unique ID generated for this entry
+ * @property {string} title The title passed to the command being run
+ * @property {() => void | Promise} undo A method to undo whatever the command did
+ * @property {() => void | Promise} redo A method to redo whatever undo did
+ * @property {() => any | Promise} export A method to export the command
+ * @property {{[key: string]: any}} state The state of the current command instance
+ * @property {{[key: string]: any}} extra Extra information saved with the command
+ */
+
+/**
+ * Extra command information
+ *
+ * @typedef CommandExtraParams
+ * @property {boolean} recordHistory The title passed to the command being run
+ * @property {any} importData Data to restore the command from
+ * @property {Record} extra Extra information to be stored in the history entry
+ */
+
+/**
+ * A command, which is run, then returns a CommandEntry object that can be used to manually undo/redo it
+ *
+ * @callback Command
+ * @param {string} title The title passed to the command being run
+ * @param {any} options A options object for the command
+ * @param {CommandExtraParams} extra A options object for the command
+ * @returns {Promise}
+ */
+
+/**
+ * A method for running a command (or redoing it)
+ *
+ * @callback CommandDoCallback
+ * @param {string} title The title passed to the command being run
+ * @param {*} options A options object for the command
+ * @param {{[key: string]: any}} state The state of the current command instance
+ * @returns {void | Promise}
+ */
+
+/**
+ * A method for undoing a command
+ *
+ * @callback CommandUndoCallback
+ * @param {string} title The title passed to the command when it was run
+ * @param {{[key: string]: any}} state The state of the current command instance
+ * @returns {void | Promise}
+ */
diff --git a/openOutpaint-webUI-extension/app/js/lib/commands.js b/openOutpaint-webUI-extension/app/js/lib/commands.js
new file mode 100644
index 0000000000000000000000000000000000000000..0dc492f23132a6241d5e9f3d1ce057842581b126
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/lib/commands.js
@@ -0,0 +1,531 @@
+/**
+ * Command pattern to allow for editing history
+ */
+
+const _commands_events = new Observer();
+
+/** Global Commands Object */
+const commands = makeReadOnly(
+ {
+ /** Current History Index Reader */
+ get current() {
+ return this._current;
+ },
+ /** Current History Index (private) */
+ _current: -1,
+ /**
+ * Command History (private)
+ *
+ * @type {CommandEntry[]}
+ */
+ _history: [],
+ /** The types of commands we can run (private) */
+ _types: {},
+
+ /**
+ * Undoes the last commands in the history
+ *
+ * @param {number} [n] Number of actions to undo
+ */
+ async undo(n = 1) {
+ for (var i = 0; i < n && this.current > -1; i++) {
+ try {
+ await this._history[this._current--].undo();
+ } catch (e) {
+ console.warn("[commands] Failed to undo command");
+ console.warn(e);
+ this._current++;
+ break;
+ }
+ }
+ },
+ /**
+ * Redoes the next commands in the history
+ *
+ * @param {number} [n] Number of actions to redo
+ */
+ async redo(n = 1) {
+ for (var i = 0; i < n && this.current + 1 < this._history.length; i++) {
+ try {
+ await this._history[++this._current].redo();
+ } catch (e) {
+ console.warn("[commands] Failed to redo command");
+ console.warn(e);
+ this._current--;
+ break;
+ }
+ }
+ },
+
+ /**
+ * Clears the history
+ */
+ async clear() {
+ await this.undo(this._history.length);
+
+ this._history.splice(0, this._history.length);
+
+ _commands_events.emit({
+ action: "clear",
+ state: {},
+ current: commands._current,
+ });
+ },
+
+ /**
+ * Imports an exported command and runs it
+ *
+ * @param {{name: string, title: string, data: any}} exported Exported command
+ */
+ async import(exported) {
+ await this.runCommand(
+ exported.command,
+ exported.title,
+ {},
+ {importData: exported.data}
+ );
+ },
+
+ /**
+ * Exports all commands in the history
+ */
+ async export() {
+ return Promise.all(
+ this._history.map(async (command) => command.export())
+ );
+ },
+
+ /**
+ * Creates a basic command, that can be done and undone
+ *
+ * They must contain a 'run' method that performs the action for the first time,
+ * a 'undo' method that undoes that action and a 'redo' method that does the
+ * action again, but without requiring parameters. 'redo' is by default the
+ * same as 'run'.
+ *
+ * The 'run' and 'redo' functions will receive a 'options' parameter which will be
+ * forwarded directly to the operation, and a 'state' parameter that
+ * can be used to store state for undoing things.
+ *
+ * The 'state' object will be passed to the 'undo' function as well.
+ *
+ * @param {string} name Command identifier (name)
+ * @param {CommandDoCallback} run A method that performs the action for the first time
+ * @param {CommandUndoCallback} undo A method that reverses what the run method did
+ * @param {object} opt Extra options
+ * @param {CommandDoCallback} opt.redo A method that redoes the action after undone (default: run)
+ * @param {(state: any) => any} opt.exportfn A method that exports a serializeable object
+ * @param {(value: any, state: any) => any} opt.importfn A method that imports a serializeable object
+ * @returns {Command}
+ */
+ createCommand(name, run, undo, opt = {}) {
+ defaultOpt(opt, {
+ redo: run,
+ exportfn: null,
+ importfn: null,
+ });
+
+ const command = async function runWrapper(title, options, extra = {}) {
+ // Create copy of options and state object
+ const copy = {};
+ Object.assign(copy, options);
+ const state = {};
+
+ defaultOpt(extra, {
+ recordHistory: true,
+ importData: null,
+ });
+
+ const exportfn =
+ opt.exportfn ?? ((state) => Object.assign({}, state.serializeable));
+ const importfn =
+ opt.importfn ??
+ ((value, state) => (state.serializeable = Object.assign({}, value)));
+ const redo = opt.redo;
+
+ /** @type {CommandEntry} */
+ const entry = {
+ id: guid(),
+ title,
+ state,
+ async export() {
+ return {
+ command: name,
+ title,
+ data: await exportfn(state),
+ };
+ },
+ extra: extra.extra,
+ };
+
+ if (extra.importData) {
+ await importfn(extra.importData, state);
+ state.imported = extra.importData;
+ }
+
+ // Attempt to run command
+ try {
+ console.debug(`[commands] Running '${title}'[${name}]`);
+ await run(title, copy, state);
+ } catch (e) {
+ console.warn(
+ `[commands] Error while running command '${name}' with options:`
+ );
+ console.warn(copy);
+ console.warn(e);
+ return;
+ }
+
+ const undoWrapper = async () => {
+ console.debug(
+ `[commands] Undoing '${title}'[${name}], currently ${this._current}`
+ );
+ await undo(title, state);
+ _commands_events.emit({
+ id: entry.id,
+ name,
+ action: "undo",
+ state,
+ current: this._current,
+ });
+ };
+ const redoWrapper = async () => {
+ console.debug(
+ `[commands] Redoing '${title}'[${name}], currently ${this._current}`
+ );
+ await redo(title, copy, state);
+ _commands_events.emit({
+ id: entry.id,
+ name,
+ action: "redo",
+ state,
+ current: this._current,
+ });
+ };
+
+ entry.undo = undoWrapper;
+ entry.redo = redoWrapper;
+
+ if (!extra.recordHistory) return entry;
+
+ // Add to history
+ if (commands._history.length > commands._current + 1) {
+ commands._history.forEach((entry, index) => {
+ if (index >= commands._current + 1)
+ _commands_events.emit({
+ id: entry.id,
+ name,
+ action: "deleted",
+ state,
+ current: this._current,
+ });
+ });
+
+ commands._history.splice(commands._current + 1);
+ }
+
+ commands._history.push(entry);
+ commands._current++;
+
+ _commands_events.emit({
+ id: entry.id,
+ name,
+ action: "run",
+ state,
+ current: commands._current,
+ });
+
+ return entry;
+ };
+
+ this._types[name] = command;
+
+ return command;
+ },
+ /**
+ * Runs a command
+ *
+ * @param {string} name The name of the command to run
+ * @param {string} title The display name of the command on the history panel view
+ * @param {any} options The options to be sent to the command to be run
+ * @param {CommandExtraParams} extra Extra running options
+ * @return {Promise<{undo: () => void, redo: () => void}>} The command's return value
+ */
+ async runCommand(name, title, options = null, extra = {}) {
+ defaultOpt(extra, {
+ recordHistory: true,
+ extra: {},
+ });
+ if (!this._types[name])
+ throw new ReferenceError(`[commands] Command '${name}' does not exist`);
+
+ return this._types[name](title, options, extra);
+ },
+ },
+ "commands",
+ ["_current"]
+);
+
+/**
+ * Draw Image Command, used to draw a Image to a context
+ */
+commands.createCommand(
+ "drawImage",
+ (title, options, state) => {
+ if (
+ !state.imported &&
+ (!options ||
+ options.image === undefined ||
+ options.x === undefined ||
+ options.y === undefined)
+ )
+ throw "Command drawImage requires options in the format: {image, x, y, w?, h?, layer?}";
+
+ // Check if we have state
+ if (!state.layer) {
+ /** @type {Layer} */
+ let layer = options.layer;
+ if (!options.layer && state.layerId)
+ layer = imageCollection.layers[state.layerId];
+
+ if (!options.layer && !state.layerId) layer = uil.layer;
+
+ state.layer = layer;
+ state.context = layer.ctx;
+
+ if (!state.imported) {
+ const canvas = document.createElement("canvas");
+ canvas.width = options.image.width;
+ canvas.height = options.image.height;
+ canvas.getContext("2d").drawImage(options.image, 0, 0);
+
+ state.image = canvas;
+
+ // Saving what was in the canvas before the command
+ const imgData = state.context.getImageData(
+ options.x,
+ options.y,
+ options.w || options.image.width,
+ options.h || options.image.height
+ );
+ state.box = {
+ x: options.x,
+ y: options.y,
+ w: options.w || options.image.width,
+ h: options.h || options.image.height,
+ };
+ // Create Image
+ const cutout = document.createElement("canvas");
+ cutout.width = state.box.w;
+ cutout.height = state.box.h;
+ cutout.getContext("2d").putImageData(imgData, 0, 0);
+ state.original = cutout;
+ }
+ }
+
+ // Apply command
+ state.context.drawImage(
+ state.image,
+ 0,
+ 0,
+ state.image.width,
+ state.image.height,
+ state.box.x,
+ state.box.y,
+ state.box.w,
+ state.box.h
+ );
+ },
+ (title, state) => {
+ // Clear destination area
+ state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h);
+ // Undo
+ state.context.drawImage(state.original, state.box.x, state.box.y);
+ },
+ {
+ exportfn: (state) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = state.image.width;
+ canvas.height = state.image.height;
+ canvas.getContext("2d").drawImage(state.image, 0, 0);
+
+ const originalc = document.createElement("canvas");
+ originalc.width = state.original.width;
+ originalc.height = state.original.height;
+ originalc.getContext("2d").drawImage(state.original, 0, 0);
+
+ return {
+ image: canvas.toDataURL(),
+ original: originalc.toDataURL(),
+ box: state.box,
+ layer: state.layer.id,
+ };
+ },
+ importfn: async (value, state) => {
+ state.box = value.box;
+ state.layerId = value.layer;
+
+ const img = document.createElement("img");
+ img.src = value.image;
+ await img.decode();
+
+ const imagec = document.createElement("canvas");
+ imagec.width = state.box.w;
+ imagec.height = state.box.h;
+ imagec.getContext("2d").drawImage(img, 0, 0);
+
+ const orig = document.createElement("img");
+ orig.src = value.original;
+ await orig.decode();
+
+ const originalc = document.createElement("canvas");
+ originalc.width = state.box.w;
+ originalc.height = state.box.h;
+ originalc.getContext("2d").drawImage(orig, 0, 0);
+
+ state.image = imagec;
+ state.original = originalc;
+ },
+ }
+);
+
+commands.createCommand(
+ "eraseImage",
+ (title, options, state) => {
+ if (
+ !state.imported &&
+ (!options ||
+ options.x === undefined ||
+ options.y === undefined ||
+ options.w === undefined ||
+ options.h === undefined)
+ )
+ throw "Command eraseImage requires options in the format: {x, y, w, h, ctx?}";
+
+ if (state.imported) {
+ state.layer = imageCollection.layers[state.layerId];
+ state.context = state.layer.ctx;
+ }
+
+ // Check if we have state
+ if (!state.layer) {
+ const layer = (options.layer || state.layerId) ?? uil.layer;
+ state.layer = layer;
+ state.mask = options.mask;
+ state.context = layer.ctx;
+
+ // Saving what was in the canvas before the command
+ state.box = {
+ x: options.x,
+ y: options.y,
+ w: options.w,
+ h: options.h,
+ };
+ // Create Image
+ const cutout = document.createElement("canvas");
+ cutout.width = state.box.w;
+ cutout.height = state.box.h;
+ cutout
+ .getContext("2d")
+ .drawImage(
+ state.context.canvas,
+ options.x,
+ options.y,
+ options.w,
+ options.h,
+ 0,
+ 0,
+ options.w,
+ options.h
+ );
+ state.original = new Image();
+ state.original.src = cutout.toDataURL();
+ }
+
+ // Apply command
+ const style = state.context.fillStyle;
+ state.context.fillStyle = "black";
+
+ const op = state.context.globalCompositeOperation;
+ state.context.globalCompositeOperation = "destination-out";
+
+ if (state.mask)
+ state.context.drawImage(
+ state.mask,
+ state.box.x,
+ state.box.y,
+ state.box.w,
+ state.box.h
+ );
+ else
+ state.context.fillRect(
+ state.box.x,
+ state.box.y,
+ state.box.w,
+ state.box.h
+ );
+
+ state.context.fillStyle = style;
+ state.context.globalCompositeOperation = op;
+ },
+ (title, state) => {
+ // Clear destination area
+ state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h);
+ // Undo
+ state.context.drawImage(state.original, state.box.x, state.box.y);
+ },
+ {
+ exportfn: (state) => {
+ let mask = null;
+
+ if (state.mask) {
+ const maskc = document.createElement("canvas");
+ maskc.width = state.mask.width;
+ maskc.height = state.mask.height;
+ maskc.getContext("2d").drawImage(state.mask, 0, 0);
+
+ mask = maskc.toDataURL();
+ }
+
+ const originalc = document.createElement("canvas");
+ originalc.width = state.original.width;
+ originalc.height = state.original.height;
+ originalc.getContext("2d").drawImage(state.original, 0, 0);
+
+ return {
+ original: originalc.toDataURL(),
+ mask,
+ box: state.box,
+ layer: state.layer.id,
+ };
+ },
+ importfn: async (value, state) => {
+ state.box = value.box;
+ state.layerId = value.layer;
+
+ if (value.mask) {
+ const mask = document.createElement("img");
+ mask.src = value.mask;
+ await mask.decode();
+
+ const maskc = document.createElement("canvas");
+ maskc.width = state.box.w;
+ maskc.height = state.box.h;
+ maskc.getContext("2d").drawImage(mask, 0, 0);
+
+ state.mask = maskc;
+ }
+
+ const orig = document.createElement("img");
+ orig.src = value.original;
+ await orig.decode();
+
+ const originalc = document.createElement("canvas");
+ originalc.width = state.box.w;
+ originalc.height = state.box.h;
+ originalc.getContext("2d").drawImage(orig, 0, 0);
+
+ state.original = originalc;
+ },
+ }
+);
diff --git a/openOutpaint-webUI-extension/app/js/lib/db.js b/openOutpaint-webUI-extension/app/js/lib/db.js
new file mode 100644
index 0000000000000000000000000000000000000000..9e9c530276c1edb17c678cfaae2b1ddda2e360b8
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/lib/db.js
@@ -0,0 +1,36 @@
+const idb = window.indexedDB.open("openoutpaint", 2);
+
+idb.onerror = (e) => {
+ console.warn("[stamp] Failed to connect to IndexedDB");
+ console.warn(e);
+};
+
+idb.onupgradeneeded = (e) => {
+ const db = e.target.result;
+
+ console.debug(`[stamp] Setting up database version ${db.version}`);
+
+ if (e.oldVersion < 1) {
+ // Resources Store
+ const resourcesStore = db.createObjectStore("resources", {
+ keyPath: "id",
+ });
+ resourcesStore.createIndex("name", "name", {unique: false});
+ }
+
+ // Workspaces Store
+ const workspacesStore = db.createObjectStore("workspaces", {
+ keyPath: "id",
+ });
+ workspacesStore.createIndex("name", "name", {unique: false});
+};
+
+/** @type {IDBDatabase} */
+let db = null;
+/** @type {Observer<{db: IDBDatabase}>} */
+const ondatabaseload = new Observer();
+
+idb.onsuccess = (e) => {
+ db = e.target.result;
+ ondatabaseload.emit({db});
+};
diff --git a/openOutpaint-webUI-extension/app/js/lib/events.js b/openOutpaint-webUI-extension/app/js/lib/events.js
new file mode 100644
index 0000000000000000000000000000000000000000..cf447890f90b53288273a58259e67dabdd9b707e
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/lib/events.js
@@ -0,0 +1,5 @@
+const events = makeReadOnly({
+ tool: {
+ dream: new Observer(),
+ },
+});
diff --git a/openOutpaint-webUI-extension/app/js/lib/input.d.js b/openOutpaint-webUI-extension/app/js/lib/input.d.js
new file mode 100644
index 0000000000000000000000000000000000000000..e9caa06bce51b1515e7a5c1f5a4ffbf4577477ff
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/lib/input.d.js
@@ -0,0 +1,125 @@
+/* Here are event types */
+/**
+ * A base event type for input handlers
+ *
+ * @typedef InputEvent
+ * @property {HTMLElement} target The target for the event
+ * @property {MouseEvent | KeyboardEvent} evn An input event
+ * @property {number} timestamp The time an event was emmited
+ */
+
+/**
+ * A base event type for input
+ */
+// TODO: Implement event typing
+/**
+ * An object for mouse event listeners
+ *
+ * @typedef OnClickEvent
+ */
+
+/* Here are mouse context types */
+/**
+ * An object for mouse button event listeners.
+ *
+ * Drag events are use timing and radius to determine if they will be triggered
+ * Paint events are triggered on any mousedown, mousemove and mouseup circunstances
+ *
+ * @typedef MouseListenerBtnContext
+ * @property {Observer} onclick A click handler
+ * @property {Observer} ondclick A double click handler
+ *
+ * @property {Observer} ondragstart A drag start handler
+ * @property {Observer} ondrag A drag handler
+ * @property {Observer} ondragend A drag end handler
+ *
+ * @property {Observer} onpaintstart A paint start handler
+ * @property {Observer} onpaint A paint handler
+ * @property {Observer} onpaintend A paint end handler
+ */
+
+/**
+ * An object for mouse event listeners
+ *
+ * @typedef MouseListenerContext
+ * @property {Observer} onany A listener for any mouse events
+ * @property {Observer} onmousemove A mouse move handler
+ * @property {Observer} onwheel A mouse wheel handler
+ * @property {Record} btn Button handlers
+ */
+
+/**
+ * This callback defines how event coordinateswill be transformed
+ * for this context. This function should set ctx.coords appropriately.
+ *
+ *
+ * @callback ContextMoveTransformer
+ * @param {MouseEvent} evn The mousemove event to be transformed
+ * @param {MouseContext} ctx The context object we are currently in
+ * @returns {void}
+ */
+
+/**
+ * A context for handling mouse coordinates and events
+ *
+ * @typedef MouseContext
+ * @property {string} id A unique identifier
+ * @property {string} name The key name
+ * @property {ContextMoveTransformer} onmove The coordinate transform callback
+ * @property {(evn) => void} onany A function to be run on any event
+ * @property {?HTMLElement} target The target
+ * @property {(evn) => boolean} validate A function to be check if we will process an event
+ * @property {MouseCoordContext} coords Coordinates object
+ * @property {MouseListenerContext} listen Listeners object
+ */
+
+/**
+ * An object for storing dragging information
+ *
+ * @typedef MouseCoordContextDragInfo
+ * @property {number} x X coordinate of drag start
+ * @property {number} y Y coordinate of drag start
+ * @property {HTMLElement} target Original element of drag
+ * @property {boolean} drag If we are in a drag
+ */
+
+/**
+ * An object for storing mouse coordinates in a context
+ *
+ * @typedef MouseCoordContext
+ * @property {{[key: string]: MouseCoordContextDragInfo}} dragging Information about mouse button drags
+ * @property {Point} prev Previous mouse position
+ * @property {Point} pos Current mouse position
+ */
+
+/* Here are keyboard-related types */
+/**
+ * Stores key states
+ *
+ * @typedef KeyboardKeyState
+ * @property {boolean} pressed If the key is currently pressed or not
+ * @property {boolean} held If the key is currently held or not
+ * @property {?number} _hold_to A timeout for detecting key holding status
+ */
+
+/* Here are the shortcut types */
+/**
+ * Keyboard shortcut callback
+ *
+ * @callback KeyboardShortcutCallback
+ * @param {KeyboardEvent} evn The keyboard event that triggered this shorcut
+ * @returns {void}
+ */
+
+/**
+ * Shortcut information
+ *
+ * @typedef KeyboardShortcut
+ * @property {string} id A unique identifier for this shortcut
+ *
+ * @property {boolean} ctrl Shortcut ctrl key state
+ * @property {boolean} alt Shortcut alt key state
+ * @property {boolean} shift Shortcut shift key state
+ *
+ * @property {KeyboardShortcutCallback} callback If the key is currently held or not
+ */
diff --git a/openOutpaint-webUI-extension/app/js/lib/input.js b/openOutpaint-webUI-extension/app/js/lib/input.js
new file mode 100644
index 0000000000000000000000000000000000000000..d5f48cf4d2d486da1aac41228bbd64948ed298b2
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/lib/input.js
@@ -0,0 +1,667 @@
+const inputConfig = {
+ clickRadius: 10, // Radius to be considered a click (pixels). If farther, turns into a drag
+ clickTiming: 500, // Timing window to be considered a click (ms). If longer, turns into a drag
+ dClickTiming: 500, // Timing window to be considered a double click (ms).
+
+ keyboardHoldTiming: 1000, // Timing window after which to consider holding a key (ms)
+};
+
+/**
+ * Mouse input processing
+ */
+// Base object generator functions
+function _mouse_observers(name = "generic_mouse_observer_array") {
+ return makeReadOnly(
+ {
+ // Simple click handler
+ onclick: new Observer(),
+ // Double click handler (will still trigger simple click handler as well)
+ ondclick: new Observer(),
+ // Drag handler
+ ondragstart: new Observer(),
+ ondrag: new Observer(),
+ ondragend: new Observer(),
+ // Paint handler (like drag handler, but with no delay); will trigger during clicks too
+ onpaintstart: new Observer(),
+ onpaint: new Observer(),
+ onpaintend: new Observer(),
+ },
+ name
+ );
+}
+
+/** Global Mouse Object */
+const mouse = {
+ /**
+ * Array of context objects
+ * @type {MouseContext[]}
+ */
+ _contexts: [],
+ /**
+ * Timestamps of the button's last down event
+ * @type {Record<,number | null>}
+ */
+ buttons: {},
+ /**
+ * Coordinate storage of mouse positions
+ * @type {{[ctxKey: string]: MouseCoordContext}}
+ */
+ coords: makeWriteOnce({}, "mouse.coords"),
+
+ /**
+ * Listener storage for event observers
+ * @type {{[ctxKey: string]: MouseListenerContext}}
+ */
+ listen: makeWriteOnce({}, "mouse.listen"),
+
+ // Register Context
+
+ /**
+ * Registers a new mouse context
+ *
+ * @param {string} name The key name of the context
+ * @param {ContextMoveTransformer} onmove The function to perform coordinate transform
+ * @param {object} options Extra options
+ * @param {HTMLElement} [options.target=null] Target filtering
+ * @param {(evn: any) => boolean} [options.validate] Checks if we will process this event or not
+ * @param {Record} [options.buttons={0: "left", 1: "middle", 2: "right"}] Custom button mapping
+ * @returns {MouseContext}
+ */
+ registerContext: (name, onmove, options = {}) => {
+ // Options
+ defaultOpt(options, {
+ target: null,
+ validate: () => true,
+ buttons: {0: "left", 1: "middle", 2: "right"},
+ });
+
+ // Context information
+ /** @type {MouseContext} */
+ const context = {
+ id: guid(),
+ name,
+ onmove,
+ target: options.target,
+ validate: options.validate,
+ buttons: options.buttons,
+ };
+
+ // Coordinate information
+ mouse.coords[name] = {
+ dragging: {},
+
+ prev: {
+ x: 0,
+ y: 0,
+ },
+
+ pos: {
+ x: 0,
+ y: 0,
+ },
+ };
+
+ // Listeners
+ const onany = new Observer();
+
+ mouse.listen[name] = {
+ onany,
+ onwheel: new Observer(),
+ onmousemove: new Observer(),
+ btn: {},
+ };
+
+ // Always process onany events first
+ mouse.listen[name].onwheel.on(
+ async (evn, state) => await onany.emit(evn, state),
+ Infinity,
+ true
+ );
+ mouse.listen[name].onmousemove.on(
+ async (evn, state) => await onany.emit(evn, state),
+ Infinity,
+ true
+ );
+
+ // Button specific items
+ Object.keys(options.buttons).forEach((index) => {
+ const button = options.buttons[index];
+ mouse.coords[name].dragging[button] = null;
+ mouse.listen[name].btn[button] = _mouse_observers(
+ `mouse.listen[${name}].btn[${button}]`
+ );
+
+ // Always process onany events first
+ mouse.listen[name].btn[button].onclick.on(
+ async (evn, state) => await onany.emit(evn, state),
+ Infinity,
+ true
+ );
+ mouse.listen[name].btn[button].ondclick.on(
+ async (evn, state) => await onany.emit(evn, state),
+ Infinity,
+ true
+ );
+ mouse.listen[name].btn[button].ondragstart.on(
+ async (evn, state) => await onany.emit(evn, state),
+ Infinity,
+ true
+ );
+ mouse.listen[name].btn[button].ondrag.on(
+ async (evn, state) => await onany.emit(evn, state),
+ Infinity,
+ true
+ );
+ mouse.listen[name].btn[button].ondragend.on(
+ async (evn, state) => await onany.emit(evn, state),
+ Infinity,
+ true
+ );
+ mouse.listen[name].btn[button].onpaintstart.on(
+ async (evn, state) => await onany.emit(evn, state),
+ Infinity,
+ true
+ );
+ mouse.listen[name].btn[button].onpaint.on(
+ async (evn, state) => await onany.emit(evn, state),
+ Infinity,
+ true
+ );
+ mouse.listen[name].btn[button].onpaintend.on(
+ async (evn, state) => await onany.emit(evn, state),
+ Infinity,
+ true
+ );
+ });
+
+ // Add to context
+ context.coords = mouse.coords[name];
+ context.listen = mouse.listen[name];
+
+ // Add to list
+ mouse._contexts.push(context);
+
+ return context;
+ },
+};
+
+const _double_click_timeout = {};
+const _drag_start_timeout = {};
+
+window.addEventListener(
+ "mousedown",
+ (evn) => {
+ const time = performance.now();
+
+ if (_double_click_timeout[evn.button]) {
+ // ondclick event
+ mouse._contexts.forEach(({target, name, buttons}) => {
+ if ((!target || target === evn.target) && buttons[evn.button])
+ mouse.listen[name].btn[buttons[evn.button]].ondclick.emit({
+ target: evn.target,
+ buttonId: evn.button,
+ x: mouse.coords[name].pos.x,
+ y: mouse.coords[name].pos.y,
+ evn,
+ timestamp: time,
+ });
+ });
+ } else {
+ // Start timer
+ _double_click_timeout[evn.button] = setTimeout(
+ () => delete _double_click_timeout[evn.button],
+ inputConfig.dClickTiming
+ );
+ }
+
+ // Set drag start timeout
+ _drag_start_timeout[evn.button] = setTimeout(() => {
+ mouse._contexts.forEach(({target, name, buttons}) => {
+ const key = buttons[evn.button];
+ if (
+ (!target || target === evn.target) &&
+ mouse.coords[name].dragging[key] &&
+ !mouse.coords[name].dragging[key].drag &&
+ key
+ ) {
+ mouse.listen[name].btn[key].ondragstart.emit({
+ target: evn.target,
+ buttonId: evn.button,
+ x: mouse.coords[name].pos.x,
+ y: mouse.coords[name].pos.y,
+ evn,
+ timestamp: time,
+ });
+
+ mouse.coords[name].dragging[key].drag = true;
+ }
+ });
+ delete _drag_start_timeout[evn.button];
+ }, inputConfig.clickTiming);
+
+ mouse.buttons[evn.button] = time;
+
+ mouse._contexts.forEach(({target, name, buttons, validate}) => {
+ const key = buttons[evn.button];
+ if (
+ (!target || target === evn.target) &&
+ key &&
+ (!validate || validate(evn))
+ ) {
+ mouse.coords[name].dragging[key] = {};
+ mouse.coords[name].dragging[key].target = evn.target;
+ Object.assign(mouse.coords[name].dragging[key], mouse.coords[name].pos);
+
+ // onpaintstart event
+ mouse.listen[name].btn[key].onpaintstart.emit({
+ target: evn.target,
+ buttonId: evn.button,
+ x: mouse.coords[name].pos.x,
+ y: mouse.coords[name].pos.y,
+ evn,
+ timestamp: performance.now(),
+ });
+ }
+ });
+ },
+ {
+ passive: false,
+ }
+);
+
+window.addEventListener(
+ "mouseup",
+ (evn) => {
+ const time = performance.now();
+
+ mouse._contexts.forEach(({target, name, buttons}) => {
+ const key = buttons[evn.button];
+ if (
+ (!target || target === evn.target) &&
+ key &&
+ mouse.coords[name].dragging[key]
+ ) {
+ const start = {
+ x: mouse.coords[name].dragging[key].x,
+ y: mouse.coords[name].dragging[key].y,
+ };
+
+ // onclick event
+ const dx = mouse.coords[name].pos.x - start.x;
+ const dy = mouse.coords[name].pos.y - start.y;
+
+ if (
+ mouse.buttons[evn.button] &&
+ time - mouse.buttons[evn.button] < inputConfig.clickTiming &&
+ dx * dx + dy * dy < inputConfig.clickRadius * inputConfig.clickRadius
+ )
+ mouse.listen[name].btn[key].onclick.emit({
+ target: evn.target,
+ buttonId: evn.button,
+ x: mouse.coords[name].pos.x,
+ y: mouse.coords[name].pos.y,
+ evn,
+ timestamp: performance.now(),
+ });
+
+ // onpaintend event
+ mouse.listen[name].btn[key].onpaintend.emit({
+ target: evn.target,
+ initialTarget: mouse.coords[name].dragging[key].target,
+ buttonId: evn.button,
+ ix: mouse.coords[name].dragging[key].x,
+ iy: mouse.coords[name].dragging[key].y,
+ x: mouse.coords[name].pos.x,
+ y: mouse.coords[name].pos.y,
+ evn,
+ timestamp: performance.now(),
+ });
+
+ // ondragend event
+ if (mouse.coords[name].dragging[key].drag)
+ mouse.listen[name].btn[key].ondragend.emit({
+ target: evn.target,
+ initialTarget: mouse.coords[name].dragging[key].target,
+ buttonId: evn.button,
+ ix: mouse.coords[name].dragging[key].x,
+ iy: mouse.coords[name].dragging[key].y,
+ x: mouse.coords[name].pos.x,
+ y: mouse.coords[name].pos.y,
+ evn,
+ timestamp: performance.now(),
+ });
+
+ mouse.coords[name].dragging[key] = null;
+ }
+ });
+
+ if (_drag_start_timeout[evn.button] !== undefined) {
+ clearTimeout(_drag_start_timeout[evn.button]);
+ delete _drag_start_timeout[evn.button];
+ }
+ mouse.buttons[evn.button] = null;
+ },
+ {passive: false}
+);
+
+window.addEventListener(
+ "mousemove",
+ (evn) => {
+ mouse._contexts.forEach(async (context) => {
+ const target = context.target;
+ const name = context.name;
+
+ if (
+ !target ||
+ (target === evn.target && (!context.validate || context.validate(evn)))
+ ) {
+ context.onmove(evn, context);
+
+ mouse.listen[name].onmousemove.emit({
+ target: evn.target,
+ px: mouse.coords[name].prev.x,
+ py: mouse.coords[name].prev.y,
+ x: mouse.coords[name].pos.x,
+ y: mouse.coords[name].pos.y,
+ evn,
+ timestamp: performance.now(),
+ });
+
+ Object.keys(context.buttons).forEach((index) => {
+ const key = context.buttons[index];
+ // ondragstart event (2)
+ if (mouse.coords[name].dragging[key]) {
+ const dx =
+ mouse.coords[name].pos.x - mouse.coords[name].dragging[key].x;
+ const dy =
+ mouse.coords[name].pos.y - mouse.coords[name].dragging[key].y;
+ if (
+ !mouse.coords[name].dragging[key].drag &&
+ dx * dx + dy * dy >=
+ inputConfig.clickRadius * inputConfig.clickRadius
+ ) {
+ mouse.listen[name].btn[key].ondragstart.emit({
+ target: evn.target,
+ buttonId: evn.button,
+ ix: mouse.coords[name].dragging[key].x,
+ iy: mouse.coords[name].dragging[key].y,
+ x: mouse.coords[name].pos.x,
+ y: mouse.coords[name].pos.y,
+ evn,
+ timestamp: performance.now(),
+ });
+
+ mouse.coords[name].dragging[key].drag = true;
+ }
+ }
+
+ // ondrag event
+ if (
+ mouse.coords[name].dragging[key] &&
+ mouse.coords[name].dragging[key].drag
+ )
+ mouse.listen[name].btn[key].ondrag.emit({
+ target: evn.target,
+ initialTarget: mouse.coords[name].dragging[key].target,
+ button: index,
+ ix: mouse.coords[name].dragging[key].x,
+ iy: mouse.coords[name].dragging[key].y,
+ px: mouse.coords[name].prev.x,
+ py: mouse.coords[name].prev.y,
+ x: mouse.coords[name].pos.x,
+ y: mouse.coords[name].pos.y,
+ evn,
+ timestamp: performance.now(),
+ });
+
+ // onpaint event
+ if (mouse.coords[name].dragging[key]) {
+ mouse.listen[name].btn[key].onpaint.emit({
+ target: evn.target,
+ initialTarget: mouse.coords[name].dragging[key].target,
+ button: index,
+ ix: mouse.coords[name].dragging[key].x,
+ iy: mouse.coords[name].dragging[key].y,
+ px: mouse.coords[name].prev.x,
+ py: mouse.coords[name].prev.y,
+ x: mouse.coords[name].pos.x,
+ y: mouse.coords[name].pos.y,
+ evn,
+ timestamp: performance.now(),
+ });
+ }
+ });
+ }
+ });
+ },
+ {passive: false}
+);
+
+window.addEventListener(
+ "wheel",
+ (evn) => {
+ // For firefox, we need to read a delta before deltaMode to force a PIXEL deltaMode read.
+ // If we read deltaMode before a delta read, deltaMode will be LINE.
+ // ref: https://bugzilla.mozilla.org/show_bug.cgi?id=1392460
+ let _discard = evn.deltaY;
+ _discard = evn.deltaMode;
+
+ mouse._contexts.forEach(({name, target, validate}) => {
+ if ((!target || target === evn.target) && (!validate || validate(evn))) {
+ mouse.listen[name].onwheel.emit({
+ target: evn.target,
+ delta: evn.deltaY,
+ deltaX: evn.deltaX,
+ deltaY: evn.deltaY,
+ deltaZ: evn.deltaZ,
+ mode: evn.deltaMode,
+ x: mouse.coords[name].pos.x,
+ y: mouse.coords[name].pos.y,
+ evn,
+ timestamp: performance.now(),
+ });
+ }
+ });
+ },
+ {passive: false}
+);
+
+mouse.registerContext("window", (evn, ctx) => {
+ ctx.coords.prev.x = ctx.coords.pos.x;
+ ctx.coords.prev.y = ctx.coords.pos.y;
+ ctx.coords.pos.x = evn.clientX;
+ ctx.coords.pos.y = evn.clientY;
+});
+/**
+ * Keyboard input processing
+ */
+/** Global Keyboard Object */
+const keyboard = {
+ /**
+ * Stores the key states for all keys
+ *
+ * @type {Record}
+ */
+ keys: {},
+
+ /**
+ * Checks if a key is pressed or not
+ *
+ * @param {string} code - The code of the key
+ * @returns {boolean}
+ */
+ isPressed(code) {
+ return !!this.keys[code] && this.keys[code].pressed;
+ },
+
+ /**
+ * Checks if a key is held or not
+ *
+ * @param {string} code - The code of the key
+ * @returns {boolean}
+ */
+ isHeld(code) {
+ return !!this.key[code] && this.keys[code].held;
+ },
+
+ /**
+ * Object storing shortcuts. Uses key as indexing for better performance.
+ * @type {Record}
+ */
+ shortcuts: {},
+ /**
+ * Adds a shortcut listener
+ *
+ * @param {object} shortcut Shortcut information
+ * @param {boolean} [shortcut.ctrl=false] If control must be pressed
+ * @param {boolean} [shortcut.alt=false] If alt must be pressed
+ * @param {boolean} [shortcut.shift=false] If shift must be pressed
+ * @param {string} shortcut.key The key code (evn.code) for the key pressed
+ * @param {KeyboardShortcutCallback} callback Will be called on shortcut detection
+ * @returns
+ */
+ onShortcut(shortcut, callback) {
+ /**
+ * Adds a shortcut handler (shorcut must be in format: {ctrl?: bool, alt?: bool, shift?: bool, key: string (code)})
+ * key must be the "code" parameter from keydown event; A key is "KeyA" for example
+ */
+ if (this.shortcuts[shortcut.key] === undefined)
+ this.shortcuts[shortcut.key] = [];
+
+ this.shortcuts[shortcut.key].push({
+ ctrl: shortcut.ctrl,
+ alt: shortcut.alt,
+ shift: shortcut.shift,
+ id: guid(),
+ callback,
+ });
+
+ return callback;
+ },
+ /**
+ * Deletes a shortcut (disables callback)
+ *
+ * @param {string | KeyboardShortcutCallback} shortcut A shortcut ID or its callback
+ * @param {string} [key=null] If you know the key code, to avoid searching all shortcuts
+ * @returns
+ */
+ deleteShortcut(shortcut, key = null) {
+ if (key) {
+ this.shortcuts[key] = this.shortcuts[key].filter(
+ (v) => v.id !== shortcut && v.callback !== shortcut
+ );
+ return;
+ }
+ this.shortcuts.keys().forEach((key) => {
+ this.shortcuts[key] = this.shortcuts[key].filter(
+ (v) => v.id !== shortcut && v.callback !== shortcut
+ );
+ });
+ },
+
+ listen: {
+ onkeydown: new Observer(),
+ onkeyup: new Observer(),
+ onkeyholdstart: new Observer(),
+ onkeyholdend: new Observer(),
+ onkeyclick: new Observer(),
+ onshortcut: new Observer(),
+ },
+};
+
+window.onkeydown = (evn) => {
+ // onkeydown event
+ keyboard.listen.onkeydown.emit({
+ target: evn.target,
+ code: evn.code,
+ key: evn.key,
+ evn,
+ });
+
+ keyboard.keys[evn.code] = {
+ pressed: true,
+ held: false,
+ _hold_to: setTimeout(() => {
+ keyboard.keys[evn.code].held = true;
+ delete keyboard.keys[evn.code]._hold_to;
+ // onkeyholdstart event
+ keyboard.listen.onkeyholdstart.emit({
+ target: evn.target,
+ code: evn.code,
+ key: evn.key,
+ evn,
+ });
+ }, inputConfig.keyboardHoldTiming),
+ };
+
+ // Process shortcuts if input target is not a text field
+ switch (evn.target.tagName.toLowerCase()) {
+ case "input":
+ case "textarea":
+ case "select":
+ case "button":
+ return; // If in an input field, do not process shortcuts
+ default:
+ // Do nothing
+ break;
+ }
+
+ const callbacks = keyboard.shortcuts[evn.code];
+
+ if (callbacks)
+ callbacks.forEach((callback) => {
+ let activate = true;
+
+ if (callback.ctrl !== null && !!callback.ctrl !== evn.ctrlKey)
+ activate = false;
+ if (callback.shift !== null && !!callback.shift !== evn.shiftKey)
+ activate = false;
+ if (callback.alt !== null && !!callback.alt !== evn.altKey)
+ activate = false;
+
+ if (activate) {
+ evn.preventDefault();
+ // onshortcut event
+ keyboard.listen.onshortcut.emit({
+ target: evn.target,
+ code: evn.code,
+ key: evn.key,
+ id: callback.id,
+ evn,
+ });
+ callback.callback(evn);
+ }
+ });
+};
+
+window.onkeyup = (evn) => {
+ // onkeyup event
+ keyboard.listen.onkeyup.emit({
+ target: evn.target,
+ code: evn.code,
+ key: evn.key,
+ evn,
+ });
+ if (keyboard.keys[evn.code] && keyboard.keys[evn.code].held) {
+ // onkeyholdend event
+ keyboard.listen.onkeyholdend.emit({
+ target: evn.target,
+ code: evn.code,
+ key: evn.key,
+ evn,
+ });
+ } else {
+ // onkeyclick event
+ keyboard.listen.onkeyclick.emit({
+ target: evn.target,
+ code: evn.code,
+ key: evn.key,
+ evn,
+ });
+ }
+
+ keyboard.keys[evn.code] = {
+ pressed: false,
+ held: false,
+ };
+};
diff --git a/openOutpaint-webUI-extension/app/js/lib/layers.d.js b/openOutpaint-webUI-extension/app/js/lib/layers.d.js
new file mode 100644
index 0000000000000000000000000000000000000000..cd331cd8c9f26789d609f6051d8c9be75be9be84
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/lib/layers.d.js
@@ -0,0 +1,47 @@
+/**
+ * A layer
+ *
+ * @typedef {object} Layer
+ * @property {string} id The id of the layer
+ * @property {string} key A identifier for the layer
+ * @property {string} name The display name of the layer
+ * @property {BoundingBox} bb The current bounding box of the layer, in layer coordinates
+ * @property {Size} resolution The resolution of the layer (canvas)
+ * @property {boolean} full If the layer is a full layer (occupies the full collection)
+ * @property {number} x The x coordinate of the layer
+ * @property {number} y The y coordinate of the layer
+ * @property {number} width The width of the layer
+ * @property {number} w The width of the layer
+ * @property {number} height The height of the layer
+ * @property {number} h The height of the layer
+ * @property {Point} origin The location of the origin ((0, 0) point) of the layer (If canvas goes from -64, -32 to 128, 512, it's (64, 32))
+ * @property {HTMLCanvasElement} canvas The canvas element of the layers
+ * @property {CanvasRenderingContext2D} ctx The context of the canvas of the layer
+ * @property {function} clear Clears the layer contents
+ * @property {function} moveAfter Moves this layer to another level (after given layer)
+ * @property {function} moveBefore Moves this layer to another level (before given layer)
+ * @property {function} moveTo Moves this layer to another location
+ * @property {function} resize Resizes the layer in place
+ * @property {function} hide Hides the layer
+ * @property {function} unhide Unhides the layer
+ */
+
+/**
+ * A layer collection
+ *
+ * @typedef {object} LayerCollection
+ * @property {string} id The id of the collection
+ * @property {string} key A identifier for the collection
+ * @property {string} name The display name of the collection
+ * @property {HTMLDivElement} element The base element of the collection
+ * @property {HTMLDivElement} inputElement The element used for input handling for the collection
+ * @property {Point} inputOffset The offset for calculating layer coordinates from input element input information
+ * @property {Point} origin The location of the origin ((0, 0) point) of the collection (If canvas goes from -64, -32 to 128, 512, it's (64, 32))
+ * @property {BoundingBox} bb The current bounding box of the collection, in layer coordinates
+ * @property {{[key: string]: Layer}} layers An object for quick access to layers of the collection
+ * @property {Size} size The size of the collection (CSS)
+ * @property {Size} resolution The resolution of the collection (canvas)
+ * @property {function} expand Expands the collection and its full layers by the specified amounts
+ * @property {function} registerLayer Registers a new layer
+ * @property {function} deleteLayer Deletes a layer from the collection
+ */
diff --git a/openOutpaint-webUI-extension/app/js/lib/layers.js b/openOutpaint-webUI-extension/app/js/lib/layers.js
new file mode 100644
index 0000000000000000000000000000000000000000..faaf811fc73ac1fd1a48d85b6585ccf11604e4b8
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/lib/layers.js
@@ -0,0 +1,683 @@
+/**
+ * This is a manager for the many canvas and content layers that compose the application
+ *
+ * It manages canvases and their locations and sizes according to current viewport views
+ */
+/**
+ * Here is where the old magic is created.
+ *
+ * This is probably not recommended, but it works and
+ * is probably the most reliable way to not break everything.
+ */
+(() => {
+ const original = {
+ drawImage: CanvasRenderingContext2D.prototype.drawImage,
+ getImageData: CanvasRenderingContext2D.prototype.getImageData,
+ putImageData: CanvasRenderingContext2D.prototype.putImageData,
+
+ // Drawing methods
+ moveTo: CanvasRenderingContext2D.prototype.moveTo,
+ lineTo: CanvasRenderingContext2D.prototype.lineTo,
+
+ arc: CanvasRenderingContext2D.prototype.arc,
+ fillRect: CanvasRenderingContext2D.prototype.fillRect,
+ clearRect: CanvasRenderingContext2D.prototype.clearRect,
+ };
+
+ // Backing up original functions to Root
+ Object.keys(original).forEach((key) => {
+ CanvasRenderingContext2D.prototype[key + "Root"] = function (...args) {
+ return original[key].call(this, ...args);
+ };
+ });
+
+ // Add basic get bounding box support (canvas coordinates)
+ Reflect.defineProperty(CanvasRenderingContext2D.prototype, "bb", {
+ get: function () {
+ return new BoundingBox({
+ x: -this.origin.x,
+ y: -this.origin.y,
+ w: this.canvas.width,
+ h: this.canvas.height,
+ });
+ },
+ });
+
+ // Modifying drawImage
+ Reflect.defineProperty(CanvasRenderingContext2D.prototype, "drawImage", {
+ value: function (...args) {
+ switch (args.length) {
+ case 3:
+ case 5:
+ if (this.origin !== undefined) {
+ args[1] += this.origin.x;
+ args[2] += this.origin.y;
+ }
+ break;
+ case 9:
+ // Check for origin on source
+ const sctx = args[0].getContext && args[0].getContext("2d");
+ if (sctx && sctx.origin !== undefined) {
+ args[1] += sctx.origin.x;
+ args[2] += sctx.origin.y;
+ }
+
+ // Check for origin on destination
+ if (this.origin !== undefined) {
+ args[5] += this.origin.x;
+ args[6] += this.origin.y;
+ }
+ break;
+ }
+ // Pass arguments through
+ return original.drawImage.call(this, ...args);
+ },
+ });
+
+ // Modifying getImageData method
+ Reflect.defineProperty(CanvasRenderingContext2D.prototype, "getImageData", {
+ value: function (...args) {
+ if (this.origin) {
+ args[0] += this.origin.x;
+ args[1] += this.origin.y;
+ }
+ // Pass arguments through
+ return original.getImageData.call(this, ...args);
+ },
+ });
+
+ // Modifying putImageData method
+ Reflect.defineProperty(CanvasRenderingContext2D.prototype, "putImageData", {
+ value: function (...args) {
+ if (this.origin) {
+ args[0] += this.origin.x;
+ args[1] += this.origin.y;
+ }
+ // Pass arguments through
+ return original.putImageData.call(this, ...args);
+ },
+ });
+
+ // Modifying moveTo method
+ Reflect.defineProperty(CanvasRenderingContext2D.prototype, "moveTo", {
+ value: function (...args) {
+ if (this.origin) {
+ args[0] += this.origin.x;
+ args[1] += this.origin.y;
+ }
+ // Pass arguments through
+ return original.moveTo.call(this, ...args);
+ },
+ });
+
+ // Modifying lineTo method
+ Reflect.defineProperty(CanvasRenderingContext2D.prototype, "lineTo", {
+ value: function (...args) {
+ if (this.origin) {
+ args[0] += this.origin.x;
+ args[1] += this.origin.y;
+ }
+ // Pass arguments through
+ return original.lineTo.call(this, ...args);
+ },
+ });
+
+ // Modifying arc
+ Reflect.defineProperty(CanvasRenderingContext2D.prototype, "arc", {
+ value: function (...args) {
+ if (this.origin) {
+ args[0] += this.origin.x;
+ args[1] += this.origin.y;
+ }
+ // Pass arguments through
+ return original.arc.call(this, ...args);
+ },
+ });
+
+ // Modifying fillRect
+ Reflect.defineProperty(CanvasRenderingContext2D.prototype, "fillRect", {
+ value: function (...args) {
+ if (this.origin) {
+ args[0] += this.origin.x;
+ args[1] += this.origin.y;
+ }
+ // Pass arguments through
+ return original.fillRect.call(this, ...args);
+ },
+ });
+ // Modifying clearRect
+ Reflect.defineProperty(CanvasRenderingContext2D.prototype, "clearRect", {
+ value: function (...args) {
+ if (this.origin) {
+ args[0] += this.origin.x;
+ args[1] += this.origin.y;
+ }
+ // Pass arguments through
+ return original.clearRect.call(this, ...args);
+ },
+ });
+})();
+// End of black magic
+
+const layers = {
+ _collections: [],
+ collections: makeWriteOnce({}, "layers.collections"),
+
+ listen: {
+ oncollectioncreate: new Observer(),
+ oncollectiondelete: new Observer(),
+
+ onlayercreate: new Observer(),
+ onlayerdelete: new Observer(),
+ },
+
+ // Registers a new collection
+ // Layer collections are a group of layers (canvases) that are rendered in tandem. (same width, height, position, transform, etc)
+ /**
+ *
+ * @param {string} key A key used to identify the collection
+ * @param {Size} size The initial size of the collection in pixels (CSS size)
+ * @param {object} options Extra options for the collection
+ * @param {string} [options.name=key] The display name of the collection
+ * @param {{key: string, options: object}} [options.initLayer] The configuration for the initial layer to be created
+ * @param {number} [options.inputSizeMultiplier=9] Size of the input area element, in pixels
+ * @param {HTMLElement} [options.targetElement] Element the collection will be inserted into
+ * @param {Size} [options.resolution=size] The resolution of the collection (canvas size). Not sure it works.
+ * @returns {LayerCollection} The newly created layer collection
+ */
+ registerCollection: (key, size, options = {}) => {
+ defaultOpt(options, {
+ // Display name for the collection
+ name: key,
+
+ // Initial layer
+ initLayer: {
+ key: "default",
+ options: {},
+ },
+
+ // Input multiplier (Size of the input element div)
+ inputSizeMultiplier: 9,
+
+ // Target
+ targetElement: document.getElementById("layer-render"),
+
+ // Resolution of the image
+ resolution: size,
+ });
+
+ if (options.inputSizeMultiplier % 2 === 0) options.inputSizeMultiplier++;
+
+ // Path used for logging purposes
+ const _logpath = "layers.collections." + key;
+
+ // Collection ID
+ const id = guid();
+
+ // Collection element
+ const element = document.createElement("div");
+ element.id = `collection-${id}`;
+ element.style.width = `${size.w}px`;
+ element.style.height = `${size.h}px`;
+ element.classList.add("collection");
+
+ // Input element (overlay element for input handling)
+ const inputel = document.createElement("div");
+ inputel.id = `collection-input-${id}`;
+ inputel.classList.add("collection-input-overlay");
+ element.appendChild(inputel);
+
+ options.targetElement.appendChild(element);
+
+ /** @type {LayerCollection} */
+ const collection = makeWriteOnce(
+ {
+ id,
+
+ _logpath,
+
+ _layers: [],
+ layers: {},
+
+ key,
+ name: options.name,
+ element,
+ inputElement: inputel,
+ _inputOffset: null,
+ get inputOffset() {
+ return this._inputOffset;
+ },
+
+ _origin: {x: 0, y: 0},
+ get origin() {
+ return {...this._origin};
+ },
+
+ get bb() {
+ return new BoundingBox({
+ x: -this.origin.x,
+ y: -this.origin.y,
+ w: this.size.w,
+ h: this.size.h,
+ });
+ },
+
+ _resizeInputDiv() {
+ // Set offset
+ const oldOffset = {...this._inputOffset};
+ this._inputOffset = {
+ x:
+ -Math.floor(options.inputSizeMultiplier / 2) * size.w -
+ this._origin.x,
+ y:
+ -Math.floor(options.inputSizeMultiplier / 2) * size.h -
+ this._origin.y,
+ };
+
+ // Resize the input element
+ this.inputElement.style.left = `${this.inputOffset.x}px`;
+ this.inputElement.style.top = `${this.inputOffset.y}px`;
+ this.inputElement.style.width = `${
+ size.w * options.inputSizeMultiplier
+ }px`;
+ this.inputElement.style.height = `${
+ size.h * options.inputSizeMultiplier
+ }px`;
+
+ // Move elements inside to new offset
+ for (const child of this.inputElement.children) {
+ if (child.style.position === "absolute") {
+ child.style.left = `${
+ parseInt(child.style.left, 10) +
+ oldOffset.x -
+ this._inputOffset.x
+ }px`;
+ child.style.top = `${
+ parseInt(child.style.top, 10) +
+ oldOffset.y -
+ this._inputOffset.y
+ }px`;
+ }
+ }
+ },
+
+ /**
+ * Expands the collection and its full layers by the specified amounts
+ *
+ * @param {number} left Pixels to expand left
+ * @param {number} top Pixels to expand top
+ * @param {number} right Pixels to expand right
+ * @param {number} bottom Pixels to expand bottom
+ */
+ expand(left, top, right, bottom) {
+ this._layers.forEach((layer) => {
+ if (layer.full) layer._expand(left, top, right, bottom);
+ });
+
+ this._origin.x += left;
+ this._origin.y += top;
+
+ this.size.w += left + right;
+ this.size.h += top + bottom;
+
+ this._resizeInputDiv();
+
+ for (const layer of this._layers) {
+ layer.moveTo(layer.x, layer.y);
+ }
+ },
+
+ size,
+ resolution: options.resolution,
+
+ /**
+ * Registers a new layer
+ *
+ * @param {string | null} key Name and key to use to access layer. If null, it is a temporary layer.
+ * @param {object} options
+ * @param {string} options.id
+ * @param {string} options.name
+ * @param {?BoundingBox} options.bb
+ * @param {string} [options.category]
+ * @param {{w: number, h: number}} options.resolution
+ * @param {?string} options.group
+ * @param {object} options.after
+ * @param {object} options.ctxOptions
+ * @returns {Layer} The newly created layer
+ */
+ registerLayer(key = null, options = {}) {
+ // Make ID
+ const id = options.id ?? guid();
+
+ defaultOpt(options, {
+ // ID of the layer
+ id: null,
+
+ // Display name for the layer
+ name: key || `Temporary ${id}`,
+
+ // Bounding box for layer
+ bb: {
+ x: -collection.origin.x,
+ y: -collection.origin.y,
+ w: collection.size.w,
+ h: collection.size.h,
+ },
+
+ // Category of the layer
+ category: null,
+
+ // Resolution for layer
+ resolution: null,
+
+ // Group for the layer ("group/subgroup/subsubgroup")
+ group: null,
+
+ // If set, will insert the layer after the given one
+ after: null,
+
+ // Context creation options
+ ctxOptions: {},
+ });
+
+ // Check if the layer is full
+ let full = false;
+ if (
+ options.bb.x === -collection.origin.x &&
+ options.bb.y === -collection.origin.y &&
+ options.bb.w === collection.size.w &&
+ options.bb.h === collection.size.h
+ )
+ full = true;
+
+ if (!options.resolution)
+ // Calculate resolution
+ options.resolution = {
+ w: Math.round(
+ (collection.resolution.w / collection.size.w) * options.bb.w
+ ),
+ h: Math.round(
+ (collection.resolution.h / collection.size.h) * options.bb.h
+ ),
+ };
+
+ // This layer's canvas
+ // This is where black magic will take place in the future
+ /**
+ * @todo Use the canvas black arts to auto-scale canvas
+ */
+ const canvas = document.createElement("canvas");
+ canvas.id = `layer-${id}`;
+
+ canvas.style.left = `${options.bb.x}px`;
+ canvas.style.top = `${options.bb.y}px`;
+ canvas.style.width = `${options.bb.w}px`;
+ canvas.style.height = `${options.bb.h}px`;
+ canvas.width = options.resolution.w;
+ canvas.height = options.resolution.h;
+
+ if (!options.after) collection.element.appendChild(canvas);
+ else {
+ options.after.canvas.after(canvas);
+ }
+
+ /**
+ * Here we set the context origin for using the black magic.
+ */
+ const ctx = canvas.getContext("2d", options.ctxOptions);
+ if (full) {
+ // Modify context to add origin information
+ ctx.origin = {
+ get x() {
+ return collection.origin.x;
+ },
+ get y() {
+ return collection.origin.y;
+ },
+ };
+ }
+
+ // Path used for logging purposes
+ const _layerlogpath = key
+ ? _logpath + ".layers." + key
+ : _logpath + ".layers[" + id + "]";
+ const layer = makeWriteOnce(
+ {
+ _logpath: _layerlogpath,
+ _collection: collection,
+
+ _bb: new BoundingBox(options.bb),
+ get bb() {
+ return new BoundingBox(this._bb);
+ },
+
+ resolution: new Size(options.resolution),
+ id,
+ key,
+ name: options.name,
+ full,
+ category: options.category,
+
+ state: new Proxy(
+ {visible: true},
+ {
+ set(obj, opt, val) {
+ switch (opt) {
+ case "visible":
+ layer.canvas.style.display = val ? "block" : "none";
+ break;
+ }
+ obj[opt] = val;
+ },
+ }
+ ),
+
+ get x() {
+ return this._bb.x;
+ },
+
+ get y() {
+ return this._bb.y;
+ },
+
+ get width() {
+ return this._bb.w;
+ },
+
+ get height() {
+ return this._bb.h;
+ },
+
+ get w() {
+ return this._bb.w;
+ },
+
+ get h() {
+ return this._bb.h;
+ },
+
+ get origin() {
+ return this._collection.origin;
+ },
+
+ get hidden() {
+ return !this.state.visible;
+ },
+
+ /** Our canvas */
+ canvas,
+ ctx,
+
+ /**
+ * This is called by the collection when the layer must be expanded.
+ *
+ * Should NOT be called directly
+ *
+ * @param {number} left Pixels to expand left
+ * @param {number} top Pixels to expand top
+ * @param {number} right Pixels to expand right
+ * @param {number} bottom Pixels to expand bottom
+ */
+ _expand(left, top, right, bottom) {
+ const tmpCanvas = document.createElement("canvas");
+ tmpCanvas.width = this.w;
+ tmpCanvas.height = this.h;
+ tmpCanvas.getContext("2d").drawImage(this.canvas, 0, 0);
+
+ this.resize(this.w + left + right, this.h + top + bottom);
+ this.clear();
+ this.ctx.drawImageRoot(tmpCanvas, left, top);
+
+ this.moveTo(this.x - left, this.y - top);
+ },
+
+ /**
+ * Clears the layer contents
+ */
+ clear() {
+ this.ctx.clearRectRoot(
+ 0,
+ 0,
+ this.canvas.width,
+ this.canvas.height
+ );
+ },
+
+ /**
+ * Recalculates DOM positioning
+ */
+ syncDOM() {
+ this.moveTo(this.x, this.y);
+ this.resize(this.w, this.h);
+ },
+
+ /**
+ * Moves this layer to another level (after given layer)
+ *
+ * @param {Layer} layer Will move layer to after this one
+ */
+ moveAfter(layer) {
+ layer.canvas.after(this.canvas);
+ },
+
+ /**
+ * Moves this layer to another level (before given layer)
+ *
+ * @param {Layer} layer Will move layer to before this one
+ */
+ moveBefore(layer) {
+ layer.canvas.before(this.canvas);
+ },
+
+ /**
+ * Moves this layer to another location
+ *
+ * @param {number} x X coordinate of the top left of the canvas
+ * @param {number} y Y coordinate of the top left of the canvas
+ */
+ moveTo(x, y) {
+ this._bb.x = x;
+ this._bb.y = y;
+ this.canvas.style.left = `${x}px`;
+ this.canvas.style.top = `${y}px`;
+ },
+
+ /**
+ * Resizes layer in place
+ *
+ * @param {number} w New width
+ * @param {number} h New height
+ */
+ resize(w, h) {
+ canvas.width = Math.round(
+ options.resolution.w * (w / options.bb.w)
+ );
+ canvas.height = Math.round(
+ options.resolution.h * (h / options.bb.h)
+ );
+ this._bb.w = w;
+ this._bb.h = h;
+ canvas.style.width = `${w}px`;
+ canvas.style.height = `${h}px`;
+ },
+
+ // Hides this layer (don't draw)
+ hide() {
+ this.state.visible = false;
+ },
+ // Hides this layer (don't draw)
+ unhide() {
+ this.state.visible = true;
+ },
+ },
+ _layerlogpath
+ );
+
+ // Add to indexers
+ if (!options.after) collection._layers.push(layer);
+ else {
+ const index = collection._layers.findIndex(
+ (l) => l === options.after
+ );
+ collection._layers.splice(index, 0, layer);
+ }
+ if (key) collection.layers[key] = layer;
+ collection.layers[id] = layer;
+
+ if (key === null)
+ console.debug(
+ `[layers] Anonymous layer '${layer.name}' registered`
+ );
+ else
+ console.info(
+ `[layers] Layer '${layer.name}' at ${layer._logpath} registered`
+ );
+
+ layers.listen.onlayercreate.emit({
+ layer,
+ });
+ return layer;
+ },
+
+ /**
+ * Deletes a layer from the collection
+ *
+ * @param {Layer} layer Layer to delete
+ */
+ deleteLayer: (layer) => {
+ const lobj = collection._layers.splice(
+ collection._layers.findIndex(
+ (l) => l.id === layer || l.id === layer.id
+ ),
+ 1
+ )[0];
+ if (!lobj) return;
+
+ layers.listen.onlayerdelete.emit({
+ layer: lobj,
+ });
+ if (lobj.key) collection.layers[lobj.key] = undefined;
+ collection.layers[lobj.id] = undefined;
+
+ collection.element.removeChild(lobj.canvas);
+
+ if (lobj.key) console.info(`[layers] Layer '${lobj.key}' deleted`);
+ else console.debug(`[layers] Anonymous layer '${lobj.id}' deleted`);
+ },
+ },
+ _logpath,
+ ["_inputOffset"]
+ );
+
+ collection._resizeInputDiv();
+
+ layers._collections.push(collection);
+ layers.collections[key] = collection;
+
+ console.info(
+ `[layers] Collection '${options.name}' at ${_logpath} registered`
+ );
+
+ return collection;
+ },
+};
diff --git a/openOutpaint-webUI-extension/app/js/lib/notifications.js b/openOutpaint-webUI-extension/app/js/lib/notifications.js
new file mode 100644
index 0000000000000000000000000000000000000000..443230d015efc6de377d8e2979fa74c0bea06f1c
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/lib/notifications.js
@@ -0,0 +1,201 @@
+/**
+ * Enum representing the location of the notifications
+ * @readonly
+ * @enum {string}
+ */
+const NotificationLocation = {
+ TOPLEFT: "top-left",
+ TOPCENTER: "top-center",
+ TOPRIGHT: "top-right",
+ BOTTOMLEFT: "bottom-left",
+ BOTTOMCENTER: "bottom-center",
+ BOTTOMRIGHT: "bottom-right",
+};
+
+/**
+ * Enum representing notification types
+ * @readonly
+ * @enum {string}
+ */
+const NotificationType = {
+ INFO: "info",
+ ERROR: "error",
+ WARN: "warn",
+ SUCCESS: "success",
+};
+
+/**
+ * Responsible for the notification system
+ */
+const notifications = {
+ /** @type {NotificationLocation} */
+ _location: NotificationLocation.BOTTOMLEFT,
+ /** @type {NotificationLocation} */
+ get location() {
+ return this.location;
+ },
+ /** @type {NotificationLocation} */
+ set location(location) {
+ this._location = location;
+ },
+
+ // Notification Area Element
+ _areaEl: null,
+
+ // Dialog BG Element
+ _dialogBGEl: null,
+ // Dialog Element
+ _dialogEl: null,
+
+ /**
+ * Creates a simple notification for the user. Equivalent to alert()
+ *
+ * @param {string | HTMLElement} message Message to display to the user.
+ * @param {Object} options Extra options for the notification.
+ * @param {NotificationType} options.type Notification type
+ * @param {boolean} options.collapsed Whether notification is collapsed by default
+ * @param {number} options.timeout Timeout for the notification
+ */
+ notify(message, options = {}) {
+ defaultOpt(options, {
+ type: NotificationType.INFO,
+ collapsed: false,
+ timeout: config.notificationTimeout,
+ });
+
+ const notificationEl = document.createElement("div");
+ notificationEl.classList.add("notification", options.type);
+ if (!options.collapsed) notificationEl.classList.add("expanded");
+ notificationEl.title = new Date().toISOString();
+ notificationEl.addEventListener("click", () =>
+ notificationEl.classList.toggle("expanded")
+ );
+
+ const contentEl = document.createElement("div");
+ contentEl.classList.add("notification-content");
+ contentEl.innerHTML = message;
+
+ notificationEl.append(contentEl);
+
+ const closeBtn = document.createElement("button");
+ closeBtn.classList.add("notification-closebtn");
+ closeBtn.addEventListener("click", () => notificationEl.remove());
+
+ notificationEl.append(closeBtn);
+
+ if (
+ config.notificationHighlightAnimationDuration &&
+ config.notificationHighlightAnimationDuration > 0
+ ) {
+ const notificationHighlightEl = document.createElement("div");
+ notificationHighlightEl.style.animationDuration = `${config.notificationHighlightAnimationDuration}ms`;
+ notificationHighlightEl.classList.add(
+ "notification-highlight",
+ `notification-${options.type}`
+ );
+
+ document.body.appendChild(notificationHighlightEl);
+ setTimeout(() => {
+ notificationHighlightEl.remove();
+ }, config.notificationHighlightAnimationDuration);
+ }
+
+ this._areaEl.prepend(notificationEl);
+ if (options.timeout)
+ setTimeout(() => {
+ if (this._areaEl.contains(notificationEl)) {
+ notificationEl.remove();
+ }
+ }, options.timeout);
+ },
+
+ /**
+ * Creates a dialog box for the user with set options.
+ *
+ * @param {string} title The title of the dialog box to be displayed to the user.
+ * @param {string | HTMLElement} message The message to be displayed to the user.
+ * @param {Object} options Extra options for the dialog.
+ * @param {Array<{label: string, value: any}>} options.choices The choices to be displayed to the user.
+ */
+ async dialog(title, message, options = {}) {
+ defaultOpt(options, {
+ // By default, it is a await notifications.dialogation dialog
+ choices: [
+ {label: "No", value: false},
+ {label: "Yes", value: true},
+ ],
+ });
+
+ const titleEl = this._dialogEl.querySelector(".dialog-title");
+ titleEl.textContent = title;
+
+ const contentEl = this._dialogEl.querySelector(".dialog-content");
+ contentEl.innerHTML = message;
+
+ const choicesEl = this._dialogEl.querySelector(".dialog-choices");
+ while (choicesEl.firstChild) {
+ choicesEl.firstChild.remove();
+ }
+
+ return new Promise((resolve, reject) => {
+ options.choices.forEach((choice) => {
+ const choiceBtn = document.createElement("button");
+ choiceBtn.textContent = choice.label;
+ choiceBtn.addEventListener("click", () => {
+ this._dialogBGEl.style.display = "none";
+ this._dialogEl.style.display = "none";
+
+ resolve(choice.value);
+ });
+
+ choicesEl.append(choiceBtn);
+ });
+
+ this._dialogBGEl.style.display = "flex";
+ this._dialogEl.style.display = "block";
+ });
+ },
+};
+var k = 0;
+
+window.addEventListener("DOMContentLoaded", () => {
+ // Creates the notification area
+ const notificationArea = document.createElement("div");
+ notificationArea.classList.add(
+ "notification-area",
+ NotificationLocation.BOTTOMLEFT
+ );
+
+ notifications._areaEl = notificationArea;
+
+ document.body.appendChild(notificationArea);
+
+ // Creates the dialog box element
+ const dialogBG = document.createElement("div");
+ dialogBG.classList.add("dialog-bg");
+ dialogBG.style.display = "none";
+
+ const dialogEl = document.createElement("div");
+ dialogEl.classList.add("dialog");
+ dialogEl.style.display = "none";
+
+ const titleEl = document.createElement("div");
+ titleEl.classList.add("dialog-title");
+
+ const contentEl = document.createElement("div");
+ contentEl.classList.add("dialog-content");
+
+ const choicesEl = document.createElement("div");
+ choicesEl.classList.add("dialog-choices");
+
+ dialogEl.append(titleEl);
+ dialogEl.append(contentEl);
+ dialogEl.append(choicesEl);
+
+ dialogBG.append(dialogEl);
+
+ notifications._dialogEl = dialogEl;
+ notifications._dialogBGEl = dialogBG;
+
+ document.body.appendChild(dialogBG);
+});
diff --git a/openOutpaint-webUI-extension/app/js/lib/toolbar.js b/openOutpaint-webUI-extension/app/js/lib/toolbar.js
new file mode 100644
index 0000000000000000000000000000000000000000..7569192dffabe0a5496fb48a4dcd07f55ee36ff5
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/lib/toolbar.js
@@ -0,0 +1,262 @@
+/**
+ * Toolbar
+ */
+
+const toolbar = {
+ _locked: false,
+ _toolbar: document.getElementById("ui-toolbar"),
+ _toolbar_lock_indicator: document.getElementById("toolbar-lock-indicator"),
+
+ tools: [],
+ _current_tool: null,
+ get currentTool() {
+ return this._current_tool;
+ },
+
+ lock() {
+ toolbar._locked = true;
+ toolbar._toolbar_lock_indicator.style.display = "block";
+ },
+ unlock() {
+ toolbar._locked = false;
+ toolbar._toolbar_lock_indicator.style.display = "none";
+ },
+
+ _makeToolbarEntry: (tool) => {
+ const toolTitle = document.createElement("img");
+ toolTitle.classList.add("tool-icon");
+ toolTitle.src = tool.icon;
+
+ const toolEl = document.createElement("div");
+ toolEl.id = `tool-${tool.id}`;
+ toolEl.classList.add("tool");
+ toolEl.title = tool.name;
+ if (tool.options.shortcut) toolEl.title += ` (${tool.options.shortcut})`;
+ toolEl.onclick = () => tool.enable();
+
+ toolEl.appendChild(toolTitle);
+
+ return toolEl;
+ },
+
+ registerTool(
+ icon,
+ toolname,
+ enable,
+ disable,
+ options = {
+ /**
+ * Runs on tool creation. It receives the tool state.
+ *
+ * Can be used to setup callback functions, for example.
+ */
+ init: null,
+ /**
+ * Function to populate the state menu.
+ *
+ * It receives a div element (that is the menu) and the current tool state.
+ */
+ populateContextMenu: null,
+ /**
+ * Help description of the tool; for now used for nothing
+ */
+ description: "",
+ /**
+ * Shortcut; Text describing this tool's shortcut access
+ */
+ shortcut: "",
+ }
+ ) {
+ // Set some defaults
+ if (!options.init)
+ options.init = (state) => console.debug(`Initialized tool '${toolname}'`);
+
+ if (!options.populateContextMenu)
+ options.populateContextMenu = (menu, state) => {
+ const span = document.createElement("span");
+ span.textContent = "Tool has no context menu";
+ menu.appendChild(span);
+ return;
+ };
+
+ // Create tool
+ const id = guid();
+
+ const contextMenuEl = document.getElementById("tool-context");
+
+ const tool = {
+ id,
+ icon,
+ name: toolname,
+ enabled: false,
+ _element: null,
+ state: {
+ redrawui: () => tool.state.redraw && tool.state.redraw(),
+ },
+ options,
+ /**
+ * If the tool has a redraw() function in its state, then run it
+ */
+ redraw: () => {
+ tool.state.redrawui && tool.state.redrawui();
+ tool.state.redraw && tool.state.redraw();
+ },
+ redrawui: () => {
+ tool.state.redrawui && tool.state.redrawui();
+ },
+ enable: (opt = null) => {
+ if (toolbar._locked) return;
+
+ this.tools.filter((t) => t.enabled).forEach((t) => t.disable());
+
+ while (contextMenuEl.lastChild) {
+ contextMenuEl.removeChild(contextMenuEl.lastChild);
+ }
+ options.populateContextMenu(contextMenuEl, tool.state, tool);
+
+ tool._element && tool._element.classList.add("using");
+ tool.enabled = true;
+
+ this._current_tool = tool;
+ enable(tool.state, opt, tool);
+ },
+ disable: (opt = null) => {
+ tool._element && tool._element.classList.remove("using");
+ this._current_tool = null;
+ tool.enabled = false;
+ disable(tool.state, opt, tool);
+ },
+ };
+
+ // Initalize
+ options.init && options.init(tool.state, tool);
+
+ this.tools.push(tool);
+
+ // Add tool to toolbar
+ tool._element = this._makeToolbarEntry(tool);
+ this._toolbar.appendChild(tool._element);
+
+ return tool;
+ },
+
+ addSeparator() {
+ const separator = document.createElement("div");
+ separator.classList.add("separator");
+ this._toolbar.appendChild(separator);
+ },
+};
+
+/**
+ * Premade inputs for populating the context menus
+ */
+const _toolbar_input = {
+ checkbox: (state, lsKey, dataKey, text, classes, cb = null) => {
+ if (state[dataKey] === undefined) state[dataKey] = false;
+
+ const savedValueStr = lsKey && localStorage.getItem(lsKey);
+ const savedValue = savedValueStr && JSON.parse(savedValueStr);
+
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.classList.add("oo-checkbox", "ui", "inline-icon");
+
+ if (savedValue !== null) state[dataKey] = checkbox.checked = savedValue;
+
+ if (typeof classes === "string") classes = [classes];
+
+ if (classes) checkbox.classList.add(...classes);
+ checkbox.checked = state[dataKey];
+ checkbox.onchange = () => {
+ if (lsKey) localStorage.setItem(lsKey, JSON.stringify(checkbox.checked));
+ state[dataKey] = checkbox.checked;
+ cb && cb();
+ };
+
+ checkbox.title = text;
+
+ const label = document.createElement("label");
+ label.appendChild(checkbox);
+ label.appendChild(new Text(text));
+
+ return {
+ checkbox,
+ label,
+ setValue(v) {
+ checkbox.checked = v;
+ state[dataKey] = checkbox.checked;
+ cb && cb();
+ return checkbox.checked;
+ },
+ };
+ },
+
+ slider: (state, lsKey, dataKey, text, options = {}) => {
+ defaultOpt(options, {min: 0, max: 1, step: 0.1, textStep: null, cb: null});
+ const slider = document.createElement("div");
+
+ const savedValueStr = lsKey && localStorage.getItem(lsKey);
+ const savedValue = savedValueStr && JSON.parse(savedValueStr);
+
+ const value = createSlider(text, slider, {
+ min: options.min,
+ max: options.max,
+ step: options.step,
+ valuecb: (v) => {
+ if (lsKey) localStorage.setItem(lsKey, JSON.stringify(v));
+ state[dataKey] = v;
+ options.cb && options.cb(v);
+ },
+ defaultValue: state[dataKey],
+ textStep: options.textStep,
+ });
+
+ if (savedValue !== null) value.value = savedValue;
+
+ return {
+ slider,
+ rawSlider: value,
+ setValue(v) {
+ value.value = v;
+ return value.value;
+ },
+ };
+ },
+
+ selectlist: (
+ state,
+ lsKey,
+ dataKey,
+ text,
+ options = {value, text},
+ defaultOptionValue,
+ cb = null
+ ) => {
+ const savedValueStr = lsKey && localStorage.getItem(lsKey);
+ const savedValue = savedValueStr && JSON.parse(savedValueStr);
+
+ const selectlist = document.createElement("select");
+ selectlist.classList.add("bareselector");
+ Object.entries(options).forEach((opt) => {
+ var option = document.createElement("option");
+ option.value = opt[0];
+ option.text = opt[1];
+ selectlist.options.add(option);
+ });
+ selectlist.selectedIndex = defaultOptionValue;
+
+ if (savedValue !== null)
+ state[dataKey] = selectlist.selectedIndex = savedValue;
+
+ selectlist.onchange = () => {
+ if (lsKey)
+ localStorage.setItem(lsKey, JSON.stringify(selectlist.selectedIndex));
+ state[dataKey] = selectlist.selectedIndex;
+ cb && cb();
+ };
+ const label = document.createElement("label");
+ label.appendChild(new Text(text));
+ label.appendChild(selectlist);
+ return {selectlist, label};
+ },
+};
diff --git a/openOutpaint-webUI-extension/app/js/lib/ui.js b/openOutpaint-webUI-extension/app/js/lib/ui.js
new file mode 100644
index 0000000000000000000000000000000000000000..36dacf453d703f8fc306d10fb91a1c1e3f76e9e6
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/lib/ui.js
@@ -0,0 +1,425 @@
+/**
+ * This is a function that makes an HTMLElement draggable.
+ *
+ * The element must contain at least one child element with the class
+ * 'draggable', which will make it the handle for dragging the element
+ *
+ * @param {HTMLElement} element Element to make Draggable
+ */
+function makeDraggable(element) {
+ let dragging = false;
+ let offset = {x: 0, y: 0};
+
+ const margin = 10;
+
+ // Keeps the draggable element inside the window
+ const fixPos = () => {
+ const dbb = element.getBoundingClientRect();
+ if (dbb.left < margin) element.style.left = margin + "px";
+ else if (dbb.right > window.innerWidth - margin)
+ element.style.left =
+ dbb.left + (window.innerWidth - margin - dbb.right) + "px";
+
+ if (dbb.top < margin) element.style.top = margin + "px";
+ else if (dbb.bottom > window.innerHeight - margin)
+ element.style.top =
+ dbb.top + (window.innerHeight - margin - dbb.bottom) + "px";
+ };
+
+ // Detects the start of the mouse dragging event
+ mouse.listen.window.btn.left.onpaintstart.on((evn) => {
+ if (
+ element.contains(evn.target) &&
+ evn.target.classList.contains("draggable")
+ ) {
+ const bb = element.getBoundingClientRect();
+ offset.x = evn.x - bb.x;
+ offset.y = evn.y - bb.y;
+ dragging = true;
+ }
+ });
+
+ // Runs when mouse moves
+ mouse.listen.window.btn.left.onpaint.on((evn) => {
+ if (dragging) {
+ element.style.right = null;
+ element.style.bottom = null;
+ element.style.top = evn.y - offset.y + "px";
+ element.style.left = evn.x - offset.x + "px";
+
+ fixPos();
+ }
+ });
+
+ // Stops dragging the element
+ mouse.listen.window.btn.left.onpaintend.on((evn) => {
+ dragging = false;
+ });
+
+ // Redraw after window resize
+ window.addEventListener("resize", () => {
+ fixPos();
+ });
+}
+
+/**
+ * Creates a custom slider element from a given div element
+ *
+ * @param {string} name The display name of the sliders
+ * @param {HTMLElement} wrapper The element to transform into a slider
+ * @param {object} options Extra options
+ * @param {number} options.min The minimum value of the slider
+ * @param {number} options.max The maximum value of the slider
+ * @param {number} options.step The step size for the slider
+ * @param {number} option.defaultValue The default value of the slider
+ * @param {number} [options.textStep=step] The step size for the slider text and setvalue \
+ * (usually finer, and an integer divisor of step size)
+ * @returns {{value: number, onchange: Observer<{value: number}>}} A reference to the value of the slider
+ */
+function createSlider(name, wrapper, options = {}) {
+ defaultOpt(options, {
+ valuecb: null,
+ min: 0,
+ max: 1,
+ step: 0.1,
+ defaultValue: 0.7,
+ textStep: null,
+ });
+
+ let value = options.defaultValue;
+
+ // Use phantom range element for rounding
+ const phantomRange = document.createElement("input");
+ phantomRange.type = "range";
+ phantomRange.min = options.min;
+ phantomRange.max = options.max;
+ phantomRange.step = options.step;
+
+ let phantomTextRange = phantomRange;
+ if (options.textStep) {
+ phantomTextRange = document.createElement("input");
+ phantomTextRange.type = "range";
+ phantomTextRange.min = options.min;
+ phantomTextRange.max = options.max;
+ phantomTextRange.step = options.textStep;
+ }
+
+ // Build slider element
+ const underEl = document.createElement("div");
+ underEl.classList.add("under");
+ const textEl = document.createElement("input");
+ textEl.type = "text";
+ textEl.classList.add("text");
+
+ const overEl = document.createElement("div");
+ overEl.classList.add("over");
+
+ wrapper.classList.add("slider-wrapper");
+ wrapper.appendChild(underEl);
+ wrapper.appendChild(textEl);
+ wrapper.appendChild(overEl);
+
+ const bar = document.createElement("div");
+ bar.classList.add("slider-bar");
+ underEl.appendChild(bar);
+ underEl.appendChild(document.createElement("div"));
+
+ // Change observer
+ /** @type {Observer<{value: number}>} */
+ const onchange = new Observer();
+
+ // Set value
+ const setValue = (val) => {
+ phantomTextRange.value = val;
+ value = parseFloat(phantomTextRange.value);
+ bar.style.width = `${
+ 100 * ((value - options.min) / (options.max - options.min))
+ }%`;
+ textEl.value = `${name}: ${value}`;
+ options.valuecb && options.valuecb(value);
+ onchange.emit({value: val});
+ };
+
+ setValue(options.defaultValue);
+
+ // Events
+ textEl.addEventListener("blur", () => {
+ overEl.style.pointerEvents = "auto";
+ textEl.value = `${name}: ${value}`;
+ });
+ textEl.addEventListener("focus", () => {
+ overEl.style.pointerEvents = "none";
+ textEl.value = value;
+ });
+
+ textEl.addEventListener("change", () => {
+ try {
+ if (Number.isNaN(parseFloat(textEl.value))) setValue(value);
+ else setValue(parseFloat(textEl.value));
+ } catch (e) {}
+ });
+
+ keyboard.listen.onkeyclick.on((evn) => {
+ if (evn.target === textEl && evn.code === "Enter") {
+ textEl.blur();
+ }
+ });
+
+ mouse.listen.window.btn.left.onclick.on((evn) => {
+ if (evn.target === overEl) {
+ textEl.select();
+ }
+ });
+
+ mouse.listen.window.btn.left.ondrag.on((evn) => {
+ if (evn.initialTarget === overEl) {
+ const newv = Math.max(
+ options.min,
+ Math.min(
+ options.max,
+ ((evn.evn.clientX - evn.initialTarget.getBoundingClientRect().left) /
+ wrapper.offsetWidth) *
+ (options.max - options.min) +
+ options.min
+ )
+ );
+ phantomRange.value = newv;
+ setValue(parseFloat(phantomRange.value));
+ }
+ });
+
+ return {
+ onchange,
+ set value(val) {
+ setValue(val);
+ },
+ get value() {
+ return value;
+ },
+ };
+}
+
+/**
+ * A function to transform a div into a autocompletable select element
+ *
+ * @param {string} name Name of the AutoComplete Select Element
+ * @param {HTMLDivElement} wrapper The div element that will wrap the input elements
+ * @param {object} options Extra options
+ * @param {boolean} options.multiple Whether multiple options can be selected
+ * @param {{name: string, value: string, optionelcb: (el: HTMLOptionElement) => void}[]} options.options Options to add to the selector
+ * @param {object} extraEl Additional element to include in wrapper div (e.g. model refresh button)
+ * @param {string} extraClass Additional class to attach to the autocomplete input element
+ */
+function createAutoComplete(
+ name,
+ wrapper,
+ options = {},
+ extraEl = null,
+ extraClass = null
+) {
+ defaultOpt(options, {
+ multiple: false,
+ options: [],
+ });
+
+ wrapper.classList.add("autocomplete");
+
+ const inputEl = document.createElement("input");
+ inputEl.type = "text";
+ inputEl.classList.add("autocomplete-text");
+ if (extraClass != null) {
+ inputEl.classList.add(extraClass);
+ }
+
+ const autocompleteEl = document.createElement("div");
+ autocompleteEl.classList.add("autocomplete-list", "display-none");
+
+ let timeout = null;
+ let ontext = false;
+ let onlist = false;
+
+ wrapper.appendChild(inputEl);
+ wrapper.appendChild(autocompleteEl);
+ if (extraEl != null) {
+ wrapper.appendChild(extraEl);
+ }
+
+ const acobj = {
+ name,
+ wrapper,
+ _selectedOptions: new Set(),
+ _options: [],
+
+ /** @type {Observer<{name:string, value: string}>} */
+ onchange: new Observer(),
+
+ get value() {
+ const v = Array.from(this._selectedOptions).map((opt) => opt.value);
+ return options.multiple ? v : v[0];
+ },
+ set value(values) {
+ this._selectedOptions.clear();
+
+ for (const val of options.multiple ? values : [values]) {
+ const opt = this.options.find((option) => option.value === val);
+
+ if (!opt) continue; // Ignore invalid options
+
+ this._selectedOptions.add(opt);
+ }
+
+ this._sync();
+ },
+
+ _sync() {
+ const val = Array.from(this._selectedOptions).map((opt) => opt.value);
+ const name = Array.from(this._selectedOptions).map((opt) => opt.name);
+
+ for (const opt of this._options) {
+ if (acobj._selectedOptions.has(opt))
+ opt.optionElement.classList.add("selected");
+ else opt.optionElement.classList.remove("selected");
+ }
+
+ updateInputField();
+
+ this.onchange.emit({
+ name: options.multiple ? name : name[0],
+ value: options.multiple ? val : val[0],
+ });
+ },
+
+ get options() {
+ return this._options;
+ },
+ set options(val) {
+ this._options = [];
+
+ while (autocompleteEl.lastChild) {
+ autocompleteEl.removeChild(autocompleteEl.lastChild);
+ }
+
+ // Add options
+ val.forEach((opt) => {
+ const {name, value, title} = opt;
+
+ const optionEl = document.createElement("option");
+ optionEl.classList.add("autocomplete-option");
+ optionEl.title = title || name;
+ if (opt.optionelcb) opt.optionelcb(optionEl);
+
+ const option = {name, value, optionElement: optionEl};
+
+ this._options.push(option);
+
+ optionEl.addEventListener("click", () => select(option));
+
+ autocompleteEl.appendChild(optionEl);
+ });
+
+ updateOptions("");
+ },
+ };
+
+ function updateInputField() {
+ inputEl.value = Array.from(acobj._selectedOptions)
+ .map((o) => o.name)
+ .join(", ");
+ inputEl.title = Array.from(acobj._selectedOptions)
+ .map((o) => o.name)
+ .join(", ");
+ }
+
+ function updateOptions(value = null) {
+ const text = value ?? inputEl.value.toLowerCase().trim();
+
+ acobj._options.forEach((opt) => {
+ const textLocation = opt.name.toLowerCase().indexOf(text);
+
+ while (opt.optionElement.lastChild) {
+ opt.optionElement.removeChild(opt.optionElement.lastChild);
+ }
+
+ opt.optionElement.append(
+ document.createTextNode(opt.name.substring(0, textLocation))
+ );
+ const span = document.createElement("span");
+ span.style.fontWeight = "bold";
+ span.textContent = opt.name.substring(
+ textLocation,
+ textLocation + text.length
+ );
+ opt.optionElement.appendChild(span);
+ opt.optionElement.appendChild(
+ document.createTextNode(
+ opt.name.substring(textLocation + text.length, opt.name.length)
+ )
+ );
+
+ if (textLocation !== -1) {
+ opt.optionElement.classList.remove("display-none");
+ } else opt.optionElement.classList.add("display-none");
+ });
+ }
+
+ function select(opt) {
+ ontext = false;
+ if (!options.multiple) {
+ onlist = false;
+ acobj._selectedOptions.clear();
+ autocompleteEl.classList.add("display-none");
+ for (const child of autocompleteEl.children) {
+ child.classList.remove("selected");
+ }
+ }
+
+ if (options.multiple && acobj._selectedOptions.has(opt)) {
+ acobj._selectedOptions.delete(opt);
+ opt.optionElement.classList.remove("selected");
+ } else {
+ acobj._selectedOptions.add(opt);
+ opt.optionElement.classList.add("selected");
+ }
+
+ acobj._sync();
+ }
+
+ inputEl.addEventListener("focus", () => {
+ ontext = true;
+
+ autocompleteEl.classList.remove("display-none");
+ inputEl.select();
+ });
+ inputEl.addEventListener("blur", () => {
+ ontext = false;
+
+ if (!onlist && !ontext) {
+ updateInputField();
+
+ autocompleteEl.classList.add("display-none");
+ }
+ });
+
+ autocompleteEl.addEventListener("mouseenter", () => {
+ onlist = true;
+ });
+
+ autocompleteEl.addEventListener("mouseleave", () => {
+ onlist = false;
+
+ if (!onlist && !ontext) {
+ updateInputField();
+
+ autocompleteEl.classList.add("display-none");
+ }
+ });
+
+ // Filter
+ inputEl.addEventListener("input", () => {
+ updateOptions();
+ });
+
+ acobj.options = options.options;
+
+ return acobj;
+}
diff --git a/openOutpaint-webUI-extension/app/js/lib/util.js b/openOutpaint-webUI-extension/app/js/lib/util.js
new file mode 100644
index 0000000000000000000000000000000000000000..2955e2d6b1c99bdd0ba628424e2205dc6b18941a
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/lib/util.js
@@ -0,0 +1,530 @@
+/**
+ * Some type definitions before the actual code
+ */
+
+/**
+ * Simple Point Coordinate
+ *
+ * @typedef Point
+ * @property {number} x - x coordinate
+ * @property {number} y - y coordinate
+ */
+
+/**
+ * Represents a size
+ */
+class Size {
+ w = 0;
+ h = 0;
+
+ constructor({w, h} = {w: 0, h: 0}) {
+ this.w = w;
+ this.h = h;
+ }
+}
+
+/**
+ * Represents a simple bouding box
+ */
+class BoundingBox {
+ x = 0;
+ y = 0;
+ w = 0;
+ h = 0;
+
+ /** @type {Point} */
+ get tl() {
+ return {x: this.x, y: this.y};
+ }
+
+ /** @type {Point} */
+ get tr() {
+ return {x: this.x + this.w, y: this.y};
+ }
+
+ /** @type {Point} */
+ get bl() {
+ return {x: this.x, y: this.y + this.h};
+ }
+
+ /** @type {Point} */
+ get br() {
+ return {x: this.x + this.w, y: this.y + this.h};
+ }
+
+ /** @type {Point} */
+ get center() {
+ return {x: this.x + this.w / 2, y: this.y + this.h / 2};
+ }
+
+ constructor({x, y, w, h} = {x: 0, y: 0, w: 0, h: 0}) {
+ this.x = x;
+ this.y = y;
+ this.w = w;
+ this.h = h;
+ }
+
+ contains(x, y) {
+ return (
+ this.x < x && this.y < y && x < this.x + this.w && y < this.y + this.h
+ );
+ }
+
+ /**
+ * Gets bounding box from two points
+ *
+ * @param {Point} start Coordinate
+ * @param {Point} end
+ */
+ static fromStartEnd(start, end) {
+ const minx = Math.min(start.x, end.x);
+ const miny = Math.min(start.y, end.y);
+ const maxx = Math.max(start.x, end.x);
+ const maxy = Math.max(start.y, end.y);
+
+ return new BoundingBox({
+ x: minx,
+ y: miny,
+ w: maxx - minx,
+ h: maxy - miny,
+ });
+ }
+
+ /**
+ * Returns a transformed bounding box (using top-left, bottom-right points)
+ *
+ * @param {DOMMatrix} transform Transformation matrix to transform points
+ */
+ transform(transform) {
+ return BoundingBox.fromStartEnd(
+ transform.transformPoint({x: this.x, y: this.y}),
+ transform.transformPoint({x: this.x + this.w, y: this.y + this.h})
+ );
+ }
+}
+
+/**
+ * A simple implementation of the Observer programming pattern
+ * @template [T=any] Message type
+ */
+class Observer {
+ /**
+ * List of handlers
+ * @type {Array<{handler: (msg: T) => void | Promise, priority: number}>}
+ */
+ _handlers = [];
+
+ /**
+ * Adds a observer to the events
+ *
+ * @param {(msg: T, state?: any) => void | Promise} callback The function to run when receiving a message
+ * @param {number} priority The priority level of the observer
+ * @param {boolean} wait If the handler must be waited for before continuing
+ * @returns {(msg:T, state?: any) => void | Promise} The callback we received
+ */
+ on(callback, priority = 0, wait = false) {
+ this._handlers.push({handler: callback, priority, wait});
+ this._handlers.sort((a, b) => b.priority - a.priority);
+ return callback;
+ }
+ /**
+ * Removes a observer
+ *
+ * @param {(msg: T, state?: any) => void | Promise} callback The function used to register the callback
+ * @returns {boolean} Whether the handler existed
+ */
+ clear(callback) {
+ const index = this._handlers.findIndex((v) => v.handler === callback);
+ if (index === -1) return false;
+ this._handlers.splice(index, 1);
+ return true;
+ }
+ /**
+ * Sends a message to all observers
+ *
+ * @param {T} msg The message to send to the observers
+ * @param {any} state The initial state
+ */
+ async emit(msg, state = {}) {
+ const promises = [];
+ for (const {handler, wait} of this._handlers) {
+ const run = async () => {
+ try {
+ await handler(msg, state);
+ } catch (e) {
+ console.warn("Observer failed to run handler");
+ console.warn(e);
+ }
+ };
+
+ if (wait) await run();
+ else promises.push(run());
+ }
+
+ return Promise.all(promises);
+ }
+}
+
+/**
+ * Static DOM utility functions
+ */
+class DOM {
+ static inputTags = new Set(["input", "textarea"]);
+
+ /**
+ * Checks if there is an active input
+ *
+ * @returns Whether there is currently an active input
+ */
+ static hasActiveInput() {
+ const active = document.activeElement;
+ const tag = active.tagName.toLowerCase();
+
+ const checkTag = this.inputTags.has(tag);
+ if (!checkTag) return false;
+
+ return tag !== "input" || active.type === "text";
+ }
+}
+
+/**
+ * Generates a simple UID in the format xxxx-xxxx-...-xxxx, with x being [0-9a-f]
+ *
+ * @param {number} [size] Number of quartets of characters to generate
+ * @returns {string} The new UID
+ */
+const guid = (size = 3) => {
+ const s4 = () => {
+ return Math.floor((1 + Math.random()) * 0x10000)
+ .toString(16)
+ .substring(1);
+ };
+ // returns id of format 'aaaa'-'aaaa'-'aaaa' by default
+ let id = "";
+ for (var i = 0; i < size - 1; i++) id += s4() + "-";
+ id += s4();
+ return id;
+};
+
+/**
+ * Returns a hash code from a string
+ *
+ * From https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript
+ *
+ * @param {String} str The string to hash
+ * @return {Number} A 32bit integer
+ */
+const hashCode = (str, seed = 0) => {
+ let h1 = 0xdeadbeef ^ seed,
+ h2 = 0x41c6ce57 ^ seed;
+ for (let i = 0, ch; i < str.length; i++) {
+ ch = str.charCodeAt(i);
+ h1 = Math.imul(h1 ^ ch, 2654435761);
+ h2 = Math.imul(h2 ^ ch, 1597334677);
+ }
+
+ h1 =
+ Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^
+ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
+ h2 =
+ Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^
+ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
+
+ return 4294967296 * (2097151 & h2) + (h1 >>> 0);
+};
+
+/**
+ * Assigns defaults to an option object passed to the function.
+ *
+ * @template T Object Type
+ *
+ * @param {T} options Original options object
+ * @param {T} defaults Default values to assign
+ */
+function defaultOpt(options, defaults) {
+ Object.keys(defaults).forEach((key) => {
+ if (options[key] === undefined) options[key] = defaults[key];
+ });
+}
+
+/** Custom error for attempt to set read-only objects */
+class ProxyReadOnlySetError extends Error {}
+/**
+ * Makes a given object read-only; throws a ProxyReadOnlySetError exception if modification is attempted
+ *
+ * @template T Object Type
+ *
+ * @param {T} obj Object to be proxied
+ * @param {string} name Name for logging purposes
+ * @param {string[]} exceptions Parameters excepted from this restriction
+ * @returns {T} Proxied object, intercepting write attempts
+ */
+function makeReadOnly(obj, name = "read-only object", exceptions = []) {
+ return new Proxy(obj, {
+ set: (obj, prop, value) => {
+ if (!exceptions.some((v) => v === prop))
+ throw new ProxyReadOnlySetError(
+ `Tried setting the '${prop}' property on '${name}'`
+ );
+ obj[prop] = value;
+ },
+ });
+}
+
+/** Custom error for attempt to set write-once objects a second time */
+class ProxyWriteOnceSetError extends Error {}
+/**
+ * Makes a given object write-once; Attempts to overwrite an existing prop in the object will throw a ProxyWriteOnceSetError exception
+ *
+ * @template T Object Type
+ * @param {T} obj Object to be proxied
+ * @param {string} [name] Name for logging purposes
+ * @param {string[]} [exceptions] Parameters excepted from this restriction
+ * @returns {T} Proxied object, intercepting write attempts
+ */
+function makeWriteOnce(obj, name = "write-once object", exceptions = []) {
+ return new Proxy(obj, {
+ set: (obj, prop, value) => {
+ if (obj[prop] !== undefined && !exceptions.some((v) => v === prop))
+ throw new ProxyWriteOnceSetError(
+ `Tried setting the '${prop}' property on '${name}' after it was already set`
+ );
+ obj[prop] = value;
+ },
+ });
+}
+
+/**
+ * Snaps a single value to an infinite grid
+ *
+ * @param {number} i Original value to be snapped
+ * @param {number} [offset=0] Value to offset the grid. Should be in the rande [0, gridSize[
+ * @param {number} [gridSize=64] Size of the grid
+ * @returns an offset, in which [i + offset = (a location snapped to the grid)]
+ */
+function snap(i, offset = 0, gridSize = config.gridSize) {
+ let diff = i - offset;
+ if (diff < 0) {
+ diff += gridSize * Math.ceil(Math.abs(diff / gridSize));
+ }
+
+ const modulus = diff % gridSize;
+ var snapOffset = modulus;
+
+ if (modulus > gridSize / 2) snapOffset = modulus - gridSize;
+
+ if (snapOffset == 0) {
+ return snapOffset;
+ }
+ return -snapOffset;
+}
+
+/**
+ * Gets a bounding box centered on a given set of coordinates. Supports grid snapping
+ *
+ * @param {number} cx - x-coordinate of the center of the box
+ * @param {number} cy - y-coordinate of the center of the box
+ * @param {number} w - the width of the box
+ * @param {height} h - the height of the box
+ * @param {?number} gridSnap - The size of the grid to snap to
+ * @param {number} [offset=0] - How much to offset the grid by
+ * @returns {BoundingBox} - A bounding box object centered at (cx, cy)
+ */
+function getBoundingBox(cx, cy, w, h, gridSnap = null, offset = 0) {
+ const offs = {x: 0, y: 0};
+ const box = {x: 0, y: 0};
+
+ if (gridSnap) {
+ offs.x = snap(cx, offset, gridSnap);
+ offs.y = snap(cy, offset, gridSnap);
+ }
+
+ box.x = Math.round(offs.x + cx);
+ box.y = Math.round(offs.y + cy);
+
+ return new BoundingBox({
+ x: Math.floor(box.x - w / 2),
+ y: Math.floor(box.y - h / 2),
+ w: Math.round(w),
+ h: Math.round(h),
+ });
+}
+
+class NoContentError extends Error {}
+
+/**
+ * Crops a given canvas to content, returning a new canvas object with the content in it.
+ *
+ * @param {HTMLCanvasElement} sourceCanvas Canvas to get a content crop from
+ * @param {object} options Extra options
+ * @param {number} [options.border=0] Extra border around the content
+ * @returns {{canvas: HTMLCanvasElement, bb: BoundingBox}} A new canvas with the cropped part of the image
+ */
+function cropCanvas(sourceCanvas, options = {}) {
+ defaultOpt(options, {border: 0});
+
+ const w = sourceCanvas.width;
+ const h = sourceCanvas.height;
+ const srcCtx = sourceCanvas.getContext("2d");
+ const offset = {
+ x: (srcCtx.origin && -srcCtx.origin.x) || 0,
+ y: (srcCtx.origin && -srcCtx.origin.y) || 0,
+ };
+ var imageData = srcCtx.getImageDataRoot(0, 0, w, h);
+ /** @type {BoundingBox} */
+ const bb = new BoundingBox();
+
+ let minx = Infinity;
+ let maxx = -Infinity;
+ let miny = Infinity;
+ let maxy = -Infinity;
+
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ // lol i need to learn what this part does
+ const index = (y * w + x) * 4; // OHHH OK this is setting the imagedata.data uint8clampeddataarray index for the specified x/y coords
+ //this part i get, this is checking that 4th RGBA byte for opacity
+ if (imageData.data[index + 3] > 0) {
+ minx = Math.min(minx, x + offset.x);
+ maxx = Math.max(maxx, x + offset.x);
+ miny = Math.min(miny, y + offset.y);
+ maxy = Math.max(maxy, y + offset.y);
+ }
+ }
+ }
+
+ bb.x = minx - options.border;
+ bb.y = miny - options.border;
+ bb.w = maxx - minx + 1 + 2 * options.border;
+ bb.h = maxy - miny + 1 + 2 * options.border;
+
+ if (!Number.isFinite(maxx))
+ throw new NoContentError("Canvas has no content to crop");
+
+ var cutCanvas = document.createElement("canvas");
+ cutCanvas.width = bb.w;
+ cutCanvas.height = bb.h;
+ cutCanvas
+ .getContext("2d")
+ .drawImage(sourceCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
+ return {canvas: cutCanvas, bb};
+}
+
+/**
+ * Downloads the content of a canvas to the disk, or opens it
+ *
+ * @param {Object} options - Optional Information
+ * @param {boolean} [options.cropToContent] - If we wish to crop to content first (default: true)
+ * @param {HTMLCanvasElement} [options.canvas] - The source canvas (default: visible)
+ * @param {string} [options.filename] - The filename to save as (default: '[ISO date] [Hours] [Minutes] [Seconds] openOutpaint image.png').\
+ * If null, opens image in new tab.
+ */
+function downloadCanvas(options = {}) {
+ defaultOpt(options, {
+ cropToContent: true,
+ canvas: uil.getVisible(imageCollection.bb),
+ filename:
+ new Date()
+ .toISOString()
+ .slice(0, 19)
+ .replace("T", " ")
+ .replace(":", " ") + " openOutpaint image.png",
+ });
+
+ var link = document.createElement("a");
+ link.target = "_blank";
+ if (options.filename) link.download = options.filename;
+
+ var croppedCanvas = options.cropToContent
+ ? cropCanvas(options.canvas).canvas
+ : options.canvas;
+
+ if (croppedCanvas != null) {
+ croppedCanvas.toBlob((blob) => {
+ link.href = URL.createObjectURL(blob);
+ link.click();
+ });
+ }
+}
+
+/**
+ * Makes an element in a location
+ * @param {string} type Element Tag
+ * @param {number} x X coordinate of the element
+ * @param {number} y Y coordinate of the element
+ * @param {{x: number y: offset}} offset Offset to apply to the element
+ * @returns
+ */
+const makeElement = (
+ type,
+ x,
+ y,
+ offset = {
+ x: -imageCollection.inputOffset.x,
+ y: -imageCollection.inputOffset.y,
+ }
+) => {
+ const el = document.createElement(type);
+ el.style.position = "absolute";
+ el.style.left = `${x + offset.x}px`;
+ el.style.top = `${y + offset.y}px`;
+
+ // We can use the input element to add interactible html elements in the world
+ imageCollection.inputElement.appendChild(el);
+
+ return el;
+};
+
+/**
+ * Subtracts identical (or damn close) pixels from new dreams
+ * @param {HTMLCanvasElement} canvas
+ * @param {BoundingBox} bb
+ * @param {HTMLImageElement} bgImg
+ * @param {number}} blur
+ * @returns {HTMLCanvasElement}
+ */
+const subtractBackground = (canvas, bb, bgImg, blur = 0, threshold = 10) => {
+ // set up temp canvases
+ const bgCanvas = document.createElement("canvas");
+ const fgCanvas = document.createElement("canvas");
+ const returnCanvas = document.createElement("canvas");
+ bgCanvas.width = fgCanvas.width = returnCanvas.width = bb.w;
+ bgCanvas.height = fgCanvas.height = returnCanvas.height = bb.h;
+ const bgCtx = bgCanvas.getContext("2d");
+ const fgCtx = fgCanvas.getContext("2d");
+ const returnCtx = returnCanvas.getContext("2d");
+ returnCtx.rect(0, 0, bb.w, bb.h);
+ returnCtx.fill();
+ // draw previous "background" image
+ bgCtx.drawImage(bgImg, 0, 0, bb.w, bb.h);
+ bgCtx.filter = "blur(" + blur + "px)";
+ // ... turn that into base64
+ const bgImgData = bgCtx.getImageData(0, 0, bb.w, bb.h);
+ // draw new image
+ fgCtx.drawImage(canvas, 0, 0);
+ const fgImgData = fgCtx.getImageData(0, 0, bb.w, bb.h);
+ for (var i = 0; i < bgImgData.data.length; i += 4) {
+ // one of these days i'm gonna learn how to use map reduce or whatever and stop iterating in for loops :(
+ // a la https://adamwathan.me/refactoring-to-collections/
+
+ // background rgb
+ var bgr = bgImgData.data[i];
+ var bgg = bgImgData.data[i + 1];
+ var bgb = bgImgData.data[i + 2];
+ // foreground rgb
+ var fgr = fgImgData.data[i];
+ var fgb = fgImgData.data[i + 1];
+ var fgd = fgImgData.data[i + 2];
+ // delta rgb
+ const dr = Math.abs(bgr - fgr) > threshold ? fgr : 0;
+ const dg = Math.abs(bgg - fgb) > threshold ? fgb : 0;
+ const db = Math.abs(bgb - fgd) > threshold ? fgd : 0;
+
+ const pxChanged = dr > 0 && dg > 0 && db > 0;
+
+ fgImgData.data[i + 3] = pxChanged ? 255 : 0;
+ }
+ returnCtx.putImageData(fgImgData, 0, 0);
+
+ return returnCanvas;
+};
diff --git a/openOutpaint-webUI-extension/app/js/prompt.js b/openOutpaint-webUI-extension/app/js/prompt.js
new file mode 100644
index 0000000000000000000000000000000000000000..781548ee8116fd31a23abda0a336012c94c21f5c
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/prompt.js
@@ -0,0 +1,198 @@
+/**
+ * This file is for processing prompt/negative prompt and prompt style data
+ */
+
+// Prompt Style Element
+const styleSelectElement = createAutoComplete(
+ "Style",
+ document.getElementById("style-ac-mselect"),
+ {multiple: true}
+);
+
+// Function to get styles from AUTOMATIC1111 webui
+async function getStyles() {
+ var url = document.getElementById("host").value + "/sdapi/v1/prompt-styles";
+ try {
+ const response = await fetch(url);
+ /** @type {{name: string, prompt: string, negative_prompt: string}[]} */
+ const data = await response.json();
+
+ /** @type {string[]} */
+ let stored = null;
+ try {
+ stored = JSON.parse(localStorage.getItem("openoutpaint/promptStyle"));
+ // doesn't seem to throw a syntaxerror if the localStorage item simply doesn't exist?
+ if (stored == null) stored = [];
+ } catch (e) {
+ stored = [];
+ }
+
+ styleSelectElement.options = data.map((style) => ({
+ name: style.name,
+ value: style.name,
+ title: `prompt: ${style.prompt}\nnegative: ${style.negative_prompt}`,
+ }));
+ styleSelectElement.onchange.on(({value}) => {
+ let selected = [];
+ if (value.find((v) => v === "None")) {
+ styleSelectElement.value = [];
+ } else {
+ selected = value;
+ }
+ stableDiffusionData.styles = selected;
+ localStorage.setItem(
+ "openoutpaint/promptStyle",
+ JSON.stringify(selected)
+ );
+ });
+
+ styleSelectElement.value = stored;
+ localStorage.setItem("openoutpaint/promptStyle", JSON.stringify(stored));
+ } catch (e) {
+ console.warn("[index] Failed to fetch prompt styles");
+ console.warn(e);
+ }
+}
+
+(async () => {
+ // Default configurations
+ const defaultPrompt =
+ "ocean floor scientific expedition, underwater wildlife";
+ const defaultNegativePrompt =
+ "people, person, humans, human, divers, diver, glitch, error, text, watermark, bad quality, blurry";
+
+ // Prompt Elements
+ const promptEl = document.getElementById("prompt");
+ const negativePromptEl = document.getElementById("negPrompt");
+
+ // Add prompt change handlers
+ promptEl.oninput = () => {
+ stableDiffusionData.prompt = promptEl.value;
+ promptEl.title = promptEl.value;
+ localStorage.setItem("openoutpaint/prompt", stableDiffusionData.prompt);
+ };
+
+ negativePromptEl.oninput = () => {
+ stableDiffusionData.negative_prompt = negativePromptEl.value;
+ negativePromptEl.title = negativePromptEl.value;
+ localStorage.setItem(
+ "openoutpaint/neg_prompt",
+ stableDiffusionData.negative_prompt
+ );
+ };
+
+ // Load from local storage if set
+ const storedPrompt = localStorage.getItem("openoutpaint/prompt");
+ const storedNeg = localStorage.getItem("openoutpaint/neg_prompt");
+ const promptDefaultValue =
+ storedPrompt === null ? defaultPrompt : storedPrompt;
+ const negativePromptDefaultValue =
+ storedNeg === null ? defaultNegativePrompt : storedNeg;
+
+ promptEl.value = promptEl.title = promptDefaultValue;
+ negativePromptEl.value = negativePromptEl.title = negativePromptDefaultValue;
+
+ /**
+ * Prompt History
+ */
+
+ // Get history-related elements
+ const promptHistoryEl = document.getElementById("prompt-history");
+
+ // History
+ const history = [];
+
+ function syncPromptHistory() {
+ const historyCopy = Array.from(history);
+ historyCopy.reverse();
+
+ for (let i = 0; i < historyCopy.length; i++) {
+ const historyItem = historyCopy[i];
+
+ const id = `prompt-history-${historyItem.id}`;
+ if (promptHistoryEl.querySelector(`#${id}`)) break;
+
+ const historyEntry = document.createElement("div");
+ historyEntry.classList.add("entry");
+ historyEntry.id = id;
+ historyEntry.title = `prompt: ${historyItem.prompt}\nnegative: ${
+ historyItem.negative
+ }\nstyles: ${historyItem.styles.join(", ")}`;
+
+ // Compare with previous
+ const samePrompt =
+ i !== historyCopy.length - 1 &&
+ historyItem.prompt === historyCopy[i + 1].prompt;
+ const sameNegativePrompt =
+ i !== historyCopy.length - 1 &&
+ historyItem.negative === historyCopy[i + 1].negative;
+ const sameStyles =
+ i !== historyCopy.length - 1 &&
+ historyItem.styles.length === historyCopy[i + 1].styles.length &&
+ !historyItem.styles.some(
+ (v, index) => v !== historyCopy[i + 1].styles[index]
+ );
+
+ const prompt = historyItem.prompt;
+ const negative = historyItem.negative;
+ const styles = historyItem.styles;
+
+ const promptBtn = document.createElement("button");
+ promptBtn.classList.add("prompt");
+ promptBtn.addEventListener("click", () => {
+ stableDiffusionData.prompt = prompt;
+ promptEl.title = prompt;
+ promptEl.value = prompt;
+ localStorage.setItem("openoutpaint/prompt", prompt);
+ });
+ promptBtn.textContent = (samePrompt ? "= " : "") + prompt;
+
+ const negativeBtn = document.createElement("button");
+ negativeBtn.classList.add("negative");
+ negativeBtn.addEventListener("click", () => {
+ stableDiffusionData.negative_prompt = negative;
+ negativePromptEl.title = negative;
+ negativePromptEl.value = negative;
+ localStorage.setItem("openoutpaint/neg_prompt", negative);
+ });
+ negativeBtn.textContent = (sameNegativePrompt ? "= " : "") + negative;
+
+ const stylesBtn = document.createElement("button");
+ stylesBtn.classList.add("styles");
+ stylesBtn.textContent = (sameStyles ? "= " : "") + styles.join(", ");
+ stylesBtn.addEventListener("click", () => {
+ styleSelectElement.value = styles;
+ });
+
+ historyEntry.appendChild(promptBtn);
+ historyEntry.appendChild(negativeBtn);
+ historyEntry.appendChild(stylesBtn);
+
+ promptHistoryEl.insertBefore(historyEntry, promptHistoryEl.firstChild);
+ }
+ }
+
+ // Listen for dreaming to add to history
+ events.tool.dream.on((message) => {
+ const {event} = message;
+ if (event === "generate") {
+ const {prompt, negative_prompt, styles} = message.request;
+ const hash = hashCode(
+ `p: ${prompt}, n: ${negative_prompt}, s: ${JSON.stringify(styles)}`
+ );
+ if (
+ !history[history.length - 1] ||
+ history[history.length - 1].hash !== hash
+ )
+ history.push({
+ id: guid(),
+ hash,
+ prompt,
+ negative: negative_prompt,
+ styles,
+ });
+ }
+
+ syncPromptHistory();
+ });
+})();
diff --git a/openOutpaint-webUI-extension/app/js/theme.js b/openOutpaint-webUI-extension/app/js/theme.js
new file mode 100644
index 0000000000000000000000000000000000000000..73acb1f1c55ea20792916683feec34192786d3b2
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/theme.js
@@ -0,0 +1,6 @@
+const theme = {
+ grid: {
+ dark: "#333",
+ light: "#555",
+ },
+};
diff --git a/openOutpaint-webUI-extension/app/js/ui/floating/history.js b/openOutpaint-webUI-extension/app/js/ui/floating/history.js
new file mode 100644
index 0000000000000000000000000000000000000000..1594f2afd7f8702a0f32e7657312e657f90ced99
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/ui/floating/history.js
@@ -0,0 +1,82 @@
+(() => {
+ const historyLogBtn = document.getElementById("history-logs-btn");
+ historyLogBtn.addEventListener("click", () => {
+ let logs = "";
+ commands._history.forEach((entry) => {
+ if (entry.extra.log) logs += ` => ${entry.extra.log}\n`;
+ });
+
+ const blob = new Blob([logs], {type: "text/plain"});
+ const url = URL.createObjectURL(blob);
+
+ var link = document.createElement("a"); // Or maybe get it from the current document
+ link.href = url;
+ link.download = `${new Date().toISOString()}_openOutpaint_log.txt`;
+ link.click();
+ });
+
+ const historyView = document.getElementById("history");
+
+ const makeHistoryEntry = (index, id, title) => {
+ const historyItemTitle = document.createElement("span");
+ historyItemTitle.classList.add(["title"]);
+ historyItemTitle.textContent = `${index} - ${title}`;
+
+ const historyItem = document.createElement("div");
+ historyItem.id = id;
+ historyItem.classList.add(["history-item"]);
+ historyItem.title = id;
+ historyItem.onclick = () => {
+ const diff = commands.current - index;
+ if (diff < 0) {
+ commands.redo(Math.abs(diff));
+ } else {
+ commands.undo(diff);
+ }
+ };
+
+ historyItem.appendChild(historyItemTitle);
+
+ return historyItem;
+ };
+
+ _commands_events.on((message) => {
+ if (message.action === "run" || message.action === "clear") {
+ Array.from(historyView.children).forEach((child) => {
+ if (
+ !commands._history.find((entry) => `hist-${entry.id}` === child.id)
+ ) {
+ historyView.removeChild(child);
+ }
+ });
+ }
+
+ commands._history.forEach((entry, index) => {
+ if (!document.getElementById(`hist-${entry.id}`)) {
+ historyView.appendChild(
+ makeHistoryEntry(index, `hist-${entry.id}`, entry.title)
+ );
+ }
+ });
+
+ Array.from(historyView.children).forEach((child, index) => {
+ if (index === commands.current) {
+ child.classList.remove(["past"]);
+ child.classList.add(["current"]);
+ child.classList.remove(["future"]);
+ } else if (index < commands.current) {
+ child.classList.add(["past"]);
+ child.classList.remove(["current"]);
+ child.classList.remove(["future"]);
+ } else {
+ child.classList.remove(["past"]);
+ child.classList.remove(["current"]);
+ child.classList.add(["future"]);
+ }
+ });
+
+ if (message.action === "run") {
+ historyView.scrollTo(0, historyView.scrollHeight);
+ }
+ });
+})();
diff --git a/openOutpaint-webUI-extension/app/js/ui/floating/layers.js b/openOutpaint-webUI-extension/app/js/ui/floating/layers.js
new file mode 100644
index 0000000000000000000000000000000000000000..4b99c8274f532789aee22d1a71af7ea0aa61aabb
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/ui/floating/layers.js
@@ -0,0 +1,737 @@
+/**
+ * The layering UI window
+ */
+
+function unzoom() {
+ viewport.zoom = 1;
+ viewport.transform(imageCollection.element);
+ //toolbar._current_tool.redrawui && toolbar._current_tool.redrawui();
+ notifications.notify("Zoom reset to 1x");
+}
+
+const uil = {
+ /** @type {Observer<{uilayer: UILayer}>} */
+ onactive: new Observer(),
+
+ _ui_layer_list: document.getElementById("layer-list"),
+ layers: [],
+ layerIndex: {},
+ _active: null,
+ set active(v) {
+ this.onactive.emit({
+ uilayer: v,
+ });
+
+ Array.from(this._ui_layer_list.children).forEach((child) => {
+ child.classList.remove("active");
+ });
+
+ v.entry.classList.add("active");
+
+ this._active = v;
+ },
+ get active() {
+ return this._active;
+ },
+
+ /** @type {Layer} */
+ get layer() {
+ return this.active && this.active.layer;
+ },
+
+ get canvas() {
+ return this.layer && this.active.layer.canvas;
+ },
+
+ get ctx() {
+ return this.layer && this.active.layer.ctx;
+ },
+
+ get w() {
+ return imageCollection.size.w;
+ },
+ get h() {
+ return imageCollection.size.h;
+ },
+
+ /**
+ * Synchronizes layer array to DOM
+ */
+ _syncLayers() {
+ const layersEl = document.getElementById("layer-list");
+
+ const copy = this.layers.map((i) => i);
+ copy.reverse();
+
+ copy.forEach((uiLayer, index) => {
+ // If we have the correct layer here, then do nothing
+ if (
+ layersEl.children[index] &&
+ layersEl.children[index].id === `ui-layer-${uiLayer.id}`
+ )
+ return;
+
+ // If the layer we are processing does not exist, then create it and add before current element
+ if (!uiLayer.entry) {
+ uiLayer.entry = document.createElement("div");
+ uiLayer.entry.id = `ui-layer-${uiLayer.id}`;
+ uiLayer.entry.classList.add("ui-layer");
+ uiLayer.entry.addEventListener("click", () => {
+ this.active = uiLayer;
+ });
+
+ // Title Element
+ const titleEl = document.createElement("input");
+ titleEl.classList.add("title");
+ titleEl.value = uiLayer.name;
+ titleEl.style.pointerEvents = "none";
+
+ const deselect = () => {
+ titleEl.style.pointerEvents = "none";
+ titleEl.setSelectionRange(0, 0);
+ };
+
+ titleEl.addEventListener("blur", deselect);
+ uiLayer.entry.appendChild(titleEl);
+
+ uiLayer.entry.addEventListener("change", () => {
+ const name = titleEl.value.trim();
+ titleEl.value = name;
+ uiLayer.entry.title = name;
+
+ uiLayer.name = name;
+
+ this._syncLayers();
+
+ titleEl.blur();
+ });
+ uiLayer.entry.addEventListener("dblclick", () => {
+ titleEl.style.pointerEvents = "auto";
+ titleEl.focus();
+ titleEl.select();
+ });
+
+ // Add action buttons
+ const actionArray = document.createElement("div");
+ actionArray.classList.add("actions");
+
+ if (uiLayer.deletable) {
+ const deleteButton = document.createElement("button");
+ deleteButton.addEventListener(
+ "click",
+ (evn) => {
+ evn.stopPropagation();
+ commands.runCommand(
+ "deleteLayer",
+ "Deleted Layer",
+ {
+ layer: uiLayer,
+ },
+ {
+ extra: {
+ log: `Deleted Layer ${uiLayer.name} [${uiLayer.id}]`,
+ },
+ }
+ );
+ },
+ {passive: false}
+ );
+
+ deleteButton.addEventListener(
+ "dblclick",
+ (evn) => {
+ evn.stopPropagation();
+ },
+ {passive: false}
+ );
+ deleteButton.title = "Delete Layer";
+ deleteButton.appendChild(document.createElement("div"));
+ deleteButton.classList.add("delete-btn");
+
+ actionArray.appendChild(deleteButton);
+ }
+
+ const hideButton = document.createElement("button");
+ hideButton.addEventListener(
+ "click",
+ (evn) => {
+ evn.stopPropagation();
+ uiLayer.hidden = !uiLayer.hidden;
+ },
+ {passive: false}
+ );
+ hideButton.addEventListener(
+ "dblclick",
+ (evn) => {
+ evn.stopPropagation();
+ },
+ {passive: false}
+ );
+ hideButton.title = "Hide/Unhide Layer";
+ hideButton.appendChild(document.createElement("div"));
+ hideButton.classList.add("hide-btn");
+
+ actionArray.appendChild(hideButton);
+ uiLayer.entry.appendChild(actionArray);
+
+ if (layersEl.children[index])
+ layersEl.children[index].before(uiLayer.entry);
+ else layersEl.appendChild(uiLayer.entry);
+ } else if (!layersEl.querySelector(`#ui-layer-${uiLayer.id}`)) {
+ // If layer exists but is not on the DOM, add it back
+ if (index === 0) layersEl.children[0].before(uiLayer.entry);
+ else layersEl.children[index - 1].after(uiLayer.entry);
+ } else {
+ // If the layer already exists, just move it here
+ layersEl.children[index].before(uiLayer.entry);
+ }
+ });
+
+ // Deletes layer if not in array
+ for (var i = 0; i < layersEl.children.length; i++) {
+ if (!copy.find((l) => layersEl.children[i].id === `ui-layer-${l.id}`)) {
+ layersEl.children[i].remove();
+ }
+ }
+
+ // Synchronizes with the layer lib
+ const ids = this.layers.map((l) => l.id);
+ ids.forEach((id, index) => {
+ if (index === 0) this.layerIndex[id].layer.moveAfter(bgLayer);
+ else
+ this.layerIndex[id].layer.moveAfter(
+ this.layerIndex[ids[index - 1]].layer
+ );
+ });
+ },
+
+ /**
+ * Adds a user-manageable layer for image editing.
+ *
+ * Should not be called directly. Use the command instead.
+ *
+ * @param {string} group The group the layer belongs to. [does nothing for now]
+ * @param {string} name The name of the new layer.
+ * @returns
+ */
+ _addLayer(group, name) {
+ const layer = imageCollection.registerLayer(null, {
+ name,
+ category: "user",
+ after:
+ (this.layers.length > 0 && this.layers[this.layers.length - 1].layer) ||
+ bgLayer,
+ });
+
+ const uiLayer = {
+ id: layer.id,
+ group,
+ name,
+ _hidden: false,
+ set hidden(v) {
+ if (v) {
+ this._hidden = true;
+ this.layer.hide(v);
+ this.entry && this.entry.classList.add("hidden");
+ } else {
+ this._hidden = false;
+ this.layer.unhide(v);
+ this.entry && this.entry.classList.remove("hidden");
+ }
+ },
+ get hidden() {
+ return this._hidden;
+ },
+ entry: null,
+ layer,
+ };
+ this.layers.push(uiLayer);
+
+ this._syncLayers();
+
+ this.active = uiLayer;
+
+ return uiLayer;
+ },
+
+ /**
+ * Moves a layer to a specified position.
+ *
+ * Should not be called directly. Use the command instead.
+ *
+ * @param {UserLayer} layer Layer to move
+ * @param {number} position Position to move the layer to
+ */
+ _moveLayerTo(layer, position) {
+ if (position < 0 || position >= this.layers.length)
+ throw new RangeError("Position out of bounds");
+
+ const index = this.layers.indexOf(layer);
+ if (index !== -1) {
+ if (this.layers.length < 2) return; // Do nothing if moving a layer doesn't make sense
+
+ this.layers.splice(index, 1);
+ this.layers.splice(position, 0, layer);
+
+ this._syncLayers();
+
+ return;
+ }
+ throw new ReferenceError("Layer could not be found");
+ },
+ /**
+ * Moves a layer up a single position.
+ *
+ * Should not be called directly. Use the command instead.
+ *
+ * @param {UserLayer} [layer=uil.active] Layer to move
+ */
+ _moveLayerUp(layer = uil.active) {
+ const index = this.layers.indexOf(layer);
+ if (index === -1) throw new ReferenceError("Layer could not be found");
+ try {
+ this._moveLayerTo(layer, index + 1);
+ } catch (e) {}
+ },
+ /**
+ * Moves a layer down a single position.
+ *
+ * Should not be called directly. Use the command instead.
+ *
+ * @param {UserLayer} [layer=uil.active] Layer to move
+ */
+ _moveLayerDown(layer = uil.active) {
+ const index = this.layers.indexOf(layer);
+ if (index === -1) throw new ReferenceError("Layer could not be found");
+ try {
+ this._moveLayerTo(layer, index - 1);
+ } catch (e) {}
+ },
+ /**
+ * Function that returns a canvas with full visible information of a certain bounding box.
+ *
+ * For now, only the img is used.
+ *
+ * @param {BoundingBox} bb The bouding box to get visible data from
+ * @param {object} [options] Options
+ * @param {boolean} [options.includeBg=false] Whether to include the background
+ * @param {string[]} [options.categories] Categories of layers to consider visible
+ * @returns {HTMLCanvasElement} The canvas element containing visible image data
+ */
+ getVisible(bb, options = {}) {
+ defaultOpt(options, {
+ includeBg: false,
+ categories: ["user", "image"],
+ });
+
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d");
+
+ canvas.width = bb.w;
+ canvas.height = bb.h;
+
+ const categories = new Set(options.categories);
+ if (options.includeBg) categories.add("background");
+ const layers = imageCollection._layers;
+
+ layers.reduceRight((_, layer) => {
+ if (categories.has(layer.category) && !layer.hidden)
+ ctx.drawImage(layer.canvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
+ });
+
+ return canvas;
+ },
+};
+
+class UILayer {
+ /** @type {string} Layer ID */
+ id;
+
+ /** @type {string} Display name of the layer */
+ name;
+
+ /** @type {Layer} Associated real layer */
+ layer;
+
+ /** @type {string} Custom key to access this layer */
+ key;
+
+ /** @type {string} The group the UI layer is on (for some categorization) */
+ group;
+
+ /** @type {boolean} If the layer displays the delete button */
+ deletable;
+
+ /** @type {HTMLElement} The entry element on the UI */
+ entry;
+
+ /** @type {boolean} [internal] Whether the layer is actually hidden right now */
+ _hidden;
+
+ /** @type {boolean} Whether the layer is hidden or not */
+ set hidden(v) {
+ if (v) {
+ this._hidden = true;
+ this.layer.hide(v);
+ this.entry && this.entry.classList.add("hidden");
+ } else {
+ this._hidden = false;
+ this.layer.unhide(v);
+ this.entry && this.entry.classList.remove("hidden");
+ }
+ }
+ get hidden() {
+ return this._hidden;
+ }
+
+ /** @type {CanvasRenderingContext2D} */
+ get ctx() {
+ return this.layer.ctx;
+ }
+
+ /** @type {HTMLCanvasElement} */
+ get canvas() {
+ return this.layer.canvas;
+ }
+
+ /**
+ * Creates a new UI Layer
+ *
+ * @param {string} name Display name of the layer
+ * @param {object} extra
+ * @param {string} extra.id The id of the layer to create
+ * @param {string} extra.group The group the layer is on (for some categorization)
+ * @param {string} extra.key Custom key to access this layer
+ * @param {string} extra.deletable If the layer displays the delete button
+ */
+ constructor(name, extra = {}) {
+ defaultOpt(extra, {
+ id: null,
+ group: null,
+ key: null,
+ deletable: true,
+ });
+
+ this.layer = imageCollection.registerLayer(extra.key, {
+ id: extra.id,
+ name,
+ category: "user",
+ after:
+ (uil.layers.length > 0 && uil.layers[uil.layers.length - 1].layer) ||
+ bgLayer,
+ });
+
+ this.name = name;
+ this.id = this.layer.id;
+ this.key = extra.key;
+ this.group = extra.group;
+ this.deletable = extra.deletable;
+
+ this.hidden = false;
+ }
+
+ /**
+ * Register layer in uil
+ */
+ register() {
+ uil.layers.push(this);
+ uil.layerIndex[this.id] = this;
+ uil.layerIndex[this.key] = this;
+ }
+
+ /**
+ * Removes layer registration from uil
+ */
+ unregister() {
+ const index = uil.layers.findIndex((v) => v === this);
+
+ if (index === -1) throw new ReferenceError("Layer could not be found");
+
+ if (uil.active === this)
+ uil.active = uil.layers[index + 1] || uil.layers[index - 1];
+ uil.layers.splice(index, 1);
+ uil.layerIndex[this.id] = undefined;
+ uil.layerIndex[this.key] = undefined;
+ }
+}
+
+/**
+ * Command for creating a new layer
+ */
+commands.createCommand(
+ "addLayer",
+ (title, opt, state) => {
+ const options = Object.assign({}, opt) || {};
+ const id = guid();
+ defaultOpt(options, {
+ id,
+ group: null,
+ name: id,
+ key: null,
+ deletable: true,
+ });
+
+ if (!state.layer) {
+ let {id, name, group, key, deletable} = state;
+
+ if (!state.imported) {
+ id = options.id;
+ name = options.name;
+ group = options.group;
+ key = options.key;
+ deletable = options.deletable;
+
+ state.name = name;
+ state.group = group;
+ state.key = key;
+ state.deletable = deletable;
+ }
+
+ state.layer = new UILayer(name, {
+ id,
+ group,
+ key: key,
+ deletable: deletable,
+ });
+
+ if (state.hidden !== undefined) state.layer.hidden = state.hidden;
+
+ state.id = state.layer.id;
+ }
+
+ state.layer.register();
+
+ uil._syncLayers();
+
+ uil.active = state.layer;
+ },
+ (title, state) => {
+ state.layer.unregister();
+
+ uil._syncLayers();
+ },
+ {
+ exportfn(state) {
+ return {
+ id: state.layer.id,
+ hidden: state.layer.hidden,
+
+ name: state.layer.name,
+ group: state.group,
+ key: state.key,
+ deletable: state.deletable,
+ };
+ },
+ importfn(value, state) {
+ state.id = value.id;
+ state.hidden = value.hidden;
+
+ state.name = value.name;
+ state.group = value.group;
+ state.key = value.key;
+ state.deletable = value.deletable;
+ },
+ }
+);
+
+/**
+ * Command for moving a layer to a position
+ */
+commands.createCommand(
+ "moveLayer",
+ (title, opt, state) => {
+ const options = opt || {};
+ defaultOpt(options, {
+ layer: null,
+ to: null,
+ delta: null,
+ });
+
+ if (!state.layer) {
+ if (options.to === null && options.delta === null)
+ throw new Error(
+ "[layers.moveLayer] Options must contain one of {to?, delta?}"
+ );
+
+ const layer = options.layer || uil.active;
+
+ const index = uil.layers.indexOf(layer);
+ if (index === -1) throw new ReferenceError("Layer could not be found");
+
+ let position = options.to;
+
+ if (position === null) position = index + options.delta;
+
+ state.layer = layer;
+ state.oldposition = index;
+ state.position = position;
+ }
+
+ uil._moveLayerTo(state.layer, state.position);
+ },
+ (title, state) => {
+ uil._moveLayerTo(state.layer, state.oldposition);
+ },
+ {
+ exportfn(state) {
+ return {
+ layer: state.layer.id,
+ position: state.position,
+ oldposition: state.oldposition,
+ };
+ },
+ importfn(value, state) {
+ state.layer = uil.layerIndex[value.layer];
+ state.position = value.position;
+ state.oldposition = value.oldposition;
+ },
+ }
+);
+
+/**
+ * Command for deleting a layer
+ */
+commands.createCommand(
+ "deleteLayer",
+ (title, opt, state) => {
+ const options = opt || {};
+ defaultOpt(options, {
+ layer: null,
+ });
+
+ if (!state.layer) {
+ const layer = options.layer || uil.active;
+
+ if (!layer.deletable)
+ throw new TypeError("[layer.deleteLayer] Layer is not deletable");
+
+ const index = uil.layers.indexOf(layer);
+ if (index === -1)
+ throw new ReferenceError(
+ "[layer.deleteLayer] Layer could not be found"
+ );
+
+ state.layer = layer;
+ state.position = index;
+ }
+
+ if (uil.active === state.layer)
+ uil.active =
+ uil.layers[state.position - 1] || uil.layers[state.position + 1];
+ uil.layers.splice(state.position, 1);
+
+ uil._syncLayers();
+
+ state.layer.hidden = true;
+ },
+ (title, state) => {
+ uil.layers.splice(state.position, 0, state.layer);
+ uil.active = state.layer;
+
+ uil._syncLayers();
+
+ state.layer.hidden = false;
+ },
+ {
+ exportfn(state) {
+ return {
+ layer: state.layer.id,
+ position: state.position,
+ };
+ },
+ importfn(value, state) {
+ state.layer = uil.layerIndex[value.layer];
+ state.position = value.position;
+ },
+ }
+);
+
+/**
+ * Command for merging a layer into the layer below it
+ */
+commands.createCommand(
+ "mergeLayer",
+ async (title, opt, state) => {
+ const options = opt || {};
+ defaultOpt(options, {
+ layerS: null,
+ layerD: null,
+ });
+
+ if (state.imported) {
+ state.layerS = uil.layerIndex[state.layerSID];
+ state.layerD = uil.layerIndex[state.layerDID];
+ }
+
+ if (!state.layerS) {
+ const layerS = options.layer || uil.active;
+
+ if (!layerS.deletable)
+ throw new TypeError(
+ "[layer.mergeLayer] Layer is a undeletable layer and cannot be merged"
+ );
+
+ const index = uil.layers.indexOf(layerS);
+ if (index === -1)
+ throw new ReferenceError("[layer.mergeLayer] Layer could not be found");
+
+ if (index === 0 && !options.layerD)
+ throw new ReferenceError(
+ "[layer.mergeLayer] No layer below source layer exists"
+ );
+
+ // Use layer under source layer to merge into if not given
+ const layerD = options.layerD || uil.layers[index - 1];
+
+ state.layerS = layerS;
+ state.layerD = layerD;
+ }
+
+ // REFERENCE: This is a great reference for metacommands (commands that use other commands)
+ // These commands should NOT record history as we are already executing a command
+ state.drawCommand = await commands.runCommand(
+ "drawImage",
+ "Merge Layer Draw",
+ {
+ image: state.layerS.layer.canvas,
+ x: 0,
+ y: 0,
+ layer: state.layerD.layer,
+ },
+ {recordHistory: false}
+ );
+ state.delCommand = await commands.runCommand(
+ "deleteLayer",
+ "Merge Layer Delete",
+ {layer: state.layerS},
+ {recordHistory: false}
+ );
+ },
+ (title, state) => {
+ state.drawCommand.undo();
+ state.delCommand.undo();
+ },
+ {
+ redo: (title, options, state) => {
+ state.drawCommand.redo();
+ state.delCommand.redo();
+ },
+ exportfn(state) {
+ return {
+ layerS: state.layerS.id,
+ layerD: state.layerD.id,
+ };
+ },
+ importfn(value, state) {
+ state.layerSID = value.layerS;
+ state.layerDID = value.layerD;
+ },
+ }
+);
+
+commands.runCommand(
+ "addLayer",
+ "Initial Layer Creation",
+ {name: "Default Image Layer", key: "default", deletable: false},
+ {recordHistory: false}
+);
diff --git a/openOutpaint-webUI-extension/app/js/ui/tool/colorbrush.js b/openOutpaint-webUI-extension/app/js/ui/tool/colorbrush.js
new file mode 100644
index 0000000000000000000000000000000000000000..1b1494222391d15ad3330a88f841a7733204bfbd
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/ui/tool/colorbrush.js
@@ -0,0 +1,479 @@
+const _color_brush_draw_callback = (evn, state) => {
+ const ctx = state.drawLayer.ctx;
+
+ ctx.strokeStyle = state.color;
+
+ ctx.filter =
+ "blur(" +
+ state.brushBlur +
+ "px) opacity(" +
+ state.brushOpacity * 100 +
+ "%)";
+ ctx.lineWidth = state.brushSize;
+ ctx.beginPath();
+ ctx.moveTo(
+ evn.px === undefined ? evn.x : evn.px,
+ evn.py === undefined ? evn.y : evn.py
+ );
+ ctx.lineTo(evn.x, evn.y);
+ ctx.lineJoin = ctx.lineCap = "round";
+ ctx.stroke();
+ ctx.filter = null;
+};
+
+const _color_brush_erase_callback = (evn, state, ctx) => {
+ ctx.save();
+ ctx.strokeStyle = "black";
+
+ ctx.filter =
+ "blur(" +
+ state.brushBlur +
+ "px) opacity(" +
+ state.brushOpacity * 100 +
+ "%)";
+ ctx.lineWidth = state.brushSize;
+ ctx.beginPath();
+ ctx.moveTo(
+ evn.px === undefined ? evn.x : evn.px,
+ evn.py === undefined ? evn.y : evn.py
+ );
+ ctx.lineTo(evn.x, evn.y);
+ ctx.lineJoin = ctx.lineCap = "round";
+ ctx.stroke();
+ ctx.restore();
+};
+
+const colorBrushTool = () =>
+ toolbar.registerTool(
+ "./res/icons/brush.svg",
+ "Color Brush",
+ (state, opt) => {
+ // Draw new cursor immediately
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+ state.movecb({
+ ...mouse.coords.world.pos,
+ evn: {
+ clientX: mouse.coords.window.pos.x,
+ clientY: mouse.coords.window.pos.y,
+ },
+ });
+
+ // Layer for eyedropper magnifiying glass
+ state.glassLayer = imageCollection.registerLayer(null, {
+ bb: {x: 0, y: 0, w: 100, h: 100},
+ resolution: {w: 7, h: 7},
+ after: maskPaintLayer,
+ });
+ state.glassLayer.hide();
+ state.glassLayer.canvas.style.imageRendering = "pixelated";
+ state.glassLayer.canvas.style.borderRadius = "50%";
+
+ state.drawLayer = imageCollection.registerLayer(null, {
+ after: imgLayer,
+ category: "display",
+ ctxOptions: {willReadFrequently: true},
+ });
+ state.drawLayer.canvas.style.filter = "opacity(70%)";
+ state.eraseLayer = imageCollection.registerLayer(null, {
+ after: imgLayer,
+ category: "processing",
+ ctxOptions: {willReadFrequently: true},
+ });
+ state.eraseLayer.hide();
+ state.eraseBackup = imageCollection.registerLayer(null, {
+ after: imgLayer,
+ category: "processing",
+ });
+ state.eraseBackup.hide();
+
+ // Start Listeners
+ mouse.listen.world.onmousemove.on(state.movecb);
+ mouse.listen.world.onwheel.on(state.wheelcb);
+
+ keyboard.listen.onkeydown.on(state.keydowncb);
+ keyboard.listen.onkeyup.on(state.keyupcb);
+ mouse.listen.world.btn.left.onclick.on(state.leftclickcb);
+
+ mouse.listen.world.btn.left.onpaintstart.on(state.drawstartcb);
+ mouse.listen.world.btn.left.onpaint.on(state.drawcb);
+ mouse.listen.world.btn.left.onpaintend.on(state.drawendcb);
+
+ mouse.listen.world.btn.right.onpaintstart.on(state.erasestartcb);
+ mouse.listen.world.btn.right.onpaint.on(state.erasecb);
+ mouse.listen.world.btn.right.onpaintend.on(state.eraseendcb);
+
+ // Display Color
+ setMask("none");
+ },
+ (state, opt) => {
+ // Clear Listeners
+ mouse.listen.world.onmousemove.clear(state.movecb);
+ mouse.listen.world.onwheel.clear(state.wheelcb);
+
+ keyboard.listen.onkeydown.clear(state.keydowncb);
+ keyboard.listen.onkeyup.clear(state.keyupcb);
+ mouse.listen.world.btn.left.onclick.clear(state.leftclickcb);
+
+ mouse.listen.world.btn.left.onpaintstart.clear(state.drawstartcb);
+ mouse.listen.world.btn.left.onpaint.clear(state.drawcb);
+ mouse.listen.world.btn.left.onpaintend.clear(state.drawendcb);
+
+ mouse.listen.world.btn.right.onpaintstart.clear(state.erasestartcb);
+ mouse.listen.world.btn.right.onpaint.clear(state.erasecb);
+ mouse.listen.world.btn.right.onpaintend.clear(state.eraseendcb);
+
+ // Delete layer
+ imageCollection.deleteLayer(state.drawLayer);
+ imageCollection.deleteLayer(state.eraseBackup);
+ imageCollection.deleteLayer(state.eraseLayer);
+ imageCollection.deleteLayer(state.glassLayer);
+
+ // Cancel any eyedropping
+ state.drawing = false;
+ state.disableDropper();
+
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+ },
+ {
+ init: (state) => {
+ state.config = {
+ brushScrollSpeed: 1 / 5,
+ minBrushSize: 2,
+ maxBrushSize: 500,
+ minBlur: 0,
+ maxBlur: 30,
+ };
+
+ state.color = "#FFFFFF";
+ state.brushSize = 32;
+ state.brushBlur = 0;
+ state.brushOpacity = 1;
+ state.affectMask = true;
+ state.block_res_change = true;
+ state.setBrushSize = (size) => {
+ state.brushSize = size;
+ state.ctxmenu.brushSizeRange.value = size;
+ state.ctxmenu.brushSizeText.value = size;
+ };
+
+ state.eyedropper = false;
+
+ state.enableDropper = () => {
+ state.eyedropper = true;
+ state.movecb(lastMouseMoveEvn);
+ state.glassLayer.unhide();
+ };
+
+ state.disableDropper = () => {
+ state.eyedropper = false;
+ state.movecb(lastMouseMoveEvn);
+ state.glassLayer.hide();
+ };
+
+ let lastMouseMoveEvn = {x: 0, y: 0};
+
+ state.movecb = (evn) => {
+ lastMouseMoveEvn = evn;
+
+ const vcp = {x: evn.evn.clientX, y: evn.evn.clientY};
+
+ // draw drawing cursor
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+
+ uiCtx.beginPath();
+ uiCtx.arc(
+ vcp.x,
+ vcp.y,
+ (state.eyedropper ? 50 : state.brushSize / 2) / viewport.zoom,
+ 0,
+ 2 * Math.PI,
+ true
+ );
+ uiCtx.strokeStyle = "black";
+ uiCtx.stroke();
+
+ // Draw eyedropper cursor and magnifiying glass
+ if (state.eyedropper) {
+ const bb = getBoundingBox(evn.x, evn.y, 7, 7, false);
+
+ const canvas = uil.getVisible(bb, {includeBg: true});
+ state.glassLayer.ctx.clearRect(0, 0, 7, 7);
+ state.glassLayer.ctx.drawImage(canvas, 0, 0);
+ state.glassLayer.moveTo(evn.x - 50, evn.y - 50);
+ } else {
+ uiCtx.beginPath();
+ uiCtx.arc(
+ vcp.x,
+ vcp.y,
+ state.brushSize / (2 * viewport.zoom),
+ 0,
+ 2 * Math.PI,
+ true
+ );
+ uiCtx.fillStyle = state.color + "50";
+ uiCtx.fill();
+ }
+ };
+
+ state.wheelcb = (evn) => {
+ state.brushSize = state.setBrushSize(
+ state.brushSize -
+ Math.floor(state.config.brushScrollSpeed * evn.delta)
+ );
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+ state.movecb(evn);
+ };
+
+ /**
+ * These are basically for eyedropper purposes
+ */
+
+ state.keydowncb = (evn) => {
+ if (lastMouseMoveEvn.target === imageCollection.inputElement)
+ switch (evn.code) {
+ case "ShiftLeft":
+ case "ShiftRight":
+ state.enableDropper();
+ break;
+ }
+ };
+
+ state.keyupcb = (evn) => {
+ switch (evn.code) {
+ case "ShiftLeft":
+ case "ShiftRight":
+ if (!keyboard.isPressed(evn.code)) {
+ state.disableDropper();
+ }
+ break;
+ }
+ };
+
+ state.leftclickcb = (evn) => {
+ if (evn.target === imageCollection.inputElement && state.eyedropper) {
+ const bb = getBoundingBox(evn.x, evn.y, 1, 1, false);
+ const visibleCanvas = uil.getVisible(bb);
+ const dat = visibleCanvas
+ .getContext("2d")
+ .getImageData(0, 0, 1, 1).data;
+ state.setColor(
+ "#" + ((dat[0] << 16) | (dat[1] << 8) | dat[2]).toString(16)
+ );
+ state.disableDropper();
+ }
+ };
+
+ state.rightclickcb = (evn) => {
+ if (evn.target === imageCollection.inputElement && state.eyedropper) {
+ state.disableDropper();
+ }
+ };
+
+ /**
+ * Here we actually paint things
+ */
+ state.drawstartcb = (evn) => {
+ if (state.eyedropper) return;
+ state.drawing = true;
+ if (state.affectMask) _mask_brush_draw_callback(evn, state);
+ _color_brush_draw_callback(evn, state);
+ };
+
+ state.drawcb = (evn) => {
+ if (state.eyedropper || !state.drawing) return;
+ if (state.affectMask) _mask_brush_draw_callback(evn, state);
+ _color_brush_draw_callback(evn, state);
+ };
+
+ state.drawendcb = (evn) => {
+ if (!state.drawing) return;
+ state.drawing = false;
+
+ const canvas = state.drawLayer.canvas;
+ const ctx = state.drawLayer.ctx;
+
+ const cropped = cropCanvas(canvas, {border: 10});
+ const bb = cropped.bb;
+
+ commands.runCommand(
+ "drawImage",
+ "Color Brush Draw",
+ {
+ image: cropped.canvas,
+ ...bb,
+ },
+ {
+ extra: {
+ log: `Color brush drawn at x: ${bb.x}, y: ${bb.y}, width: ${bb.w}, height: ${bb.h}`,
+ },
+ }
+ );
+
+ ctx.clearRect(bb.x, bb.y, bb.w, bb.h);
+ };
+
+ state.erasestartcb = (evn) => {
+ if (state.eyedropper) return;
+ state.erasing = true;
+ if (state.affectMask) _mask_brush_erase_callback(evn, state);
+
+ // Make a backup of the current image to apply erase later
+ const bkpctx = state.eraseBackup.ctx;
+ state.eraseBackup.clear();
+ bkpctx.drawImageRoot(uil.canvas, 0, 0);
+
+ uil.ctx.globalCompositeOperation = "destination-out";
+ _color_brush_erase_callback(evn, state, uil.ctx);
+ uil.ctx.globalCompositeOperation = "source-over";
+ _color_brush_erase_callback(evn, state, state.eraseLayer.ctx);
+ };
+
+ state.erasecb = (evn) => {
+ if (state.eyedropper || !state.erasing) return;
+ if (state.affectMask) _mask_brush_erase_callback(evn, state);
+ uil.ctx.globalCompositeOperation = "destination-out";
+ _color_brush_erase_callback(evn, state, uil.ctx);
+ uil.ctx.globalCompositeOperation = "source-over";
+ _color_brush_erase_callback(evn, state, state.eraseLayer.ctx);
+ };
+
+ state.eraseendcb = (evn) => {
+ if (!state.erasing) return;
+ state.erasing = false;
+
+ const canvas = state.eraseLayer.canvas;
+ const ctx = state.eraseLayer.ctx;
+
+ const bkpcanvas = state.eraseBackup.canvas;
+
+ const cropped = cropCanvas(canvas, {border: 10});
+ const bb = cropped.bb;
+
+ uil.ctx.filter = null;
+ uil.layer.clear();
+ uil.ctx.drawImageRoot(bkpcanvas, 0, 0);
+
+ commands.runCommand(
+ "eraseImage",
+ "Color Brush Erase",
+ {
+ mask: cropped.canvas,
+ ...bb,
+ },
+ {
+ extra: {
+ log: `Color brush erase at x: ${bb.x}, y: ${bb.y}, width: ${bb.w}, height: ${bb.h}`,
+ },
+ }
+ );
+
+ ctx.clearRect(bb.x, bb.y, bb.w, bb.h);
+ };
+ },
+ populateContextMenu: (menu, state) => {
+ if (!state.ctxmenu) {
+ state.ctxmenu = {};
+
+ // Affects Mask Checkbox
+ const array = document.createElement("div");
+ const affectMaskCheckbox = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/colorbrush-affectmask",
+ "affectMask",
+ "Affect Mask",
+ "icon-venetian-mask"
+ ).checkbox;
+ array.appendChild(affectMaskCheckbox);
+
+ state.ctxmenu.affectMaskCheckbox = array;
+
+ // Brush size slider
+ const brushSizeSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/colorbrush-brushsize",
+ "brushSize",
+ "Brush Size",
+ {
+ min: state.config.minBrushSize,
+ max: state.config.maxBrushSize,
+ step: 5,
+ textStep: 1,
+ }
+ );
+ state.ctxmenu.brushSizeSlider = brushSizeSlider.slider;
+ state.setBrushSize = brushSizeSlider.setValue;
+
+ // Brush opacity slider
+ const brushOpacitySlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/colorbrush-brushopacity",
+ "brushOpacity",
+ "Brush Opacity",
+ {
+ min: 0,
+ max: 1,
+ step: 0.05,
+ textStep: 0.001,
+ }
+ );
+ state.ctxmenu.brushOpacitySlider = brushOpacitySlider.slider;
+
+ // Brush blur slider
+ const brushBlurSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/colorbrush-brushblur",
+ "brushBlur",
+ "Brush Blur",
+ {
+ min: state.config.minBlur,
+ max: state.config.maxBlur,
+ step: 1,
+ }
+ );
+ state.ctxmenu.brushBlurSlider = brushBlurSlider.slider;
+
+ // Brush color
+ const brushColorPickerWrapper = document.createElement("div");
+ brushColorPickerWrapper.classList.add(
+ "brush-color-picker",
+ "wrapper"
+ );
+
+ const brushColorPicker = document.createElement("input");
+ brushColorPicker.classList.add("brush-color-picker", "picker");
+ brushColorPicker.type = "color";
+ brushColorPicker.value = state.color;
+ brushColorPicker.addEventListener("input", (evn) => {
+ state.color = evn.target.value;
+ });
+
+ state.setColor = (color) => {
+ brushColorPicker.value = color;
+ state.color = brushColorPicker.value;
+ };
+
+ const brushColorEyeDropper = document.createElement("button");
+ brushColorEyeDropper.classList.add(
+ "brush-color-picker",
+ "eyedropper"
+ );
+ brushColorEyeDropper.addEventListener("click", () => {
+ if (state.eyedropper) state.disableDropper();
+ else state.enableDropper();
+ });
+
+ brushColorPickerWrapper.appendChild(brushColorPicker);
+ brushColorPickerWrapper.appendChild(brushColorEyeDropper);
+
+ state.ctxmenu.brushColorPicker = brushColorPickerWrapper;
+ }
+
+ menu.appendChild(state.ctxmenu.affectMaskCheckbox);
+ menu.appendChild(state.ctxmenu.brushSizeSlider);
+ menu.appendChild(state.ctxmenu.brushOpacitySlider);
+ menu.appendChild(state.ctxmenu.brushBlurSlider);
+ menu.appendChild(state.ctxmenu.brushColorPicker);
+ },
+ shortcut: "C",
+ }
+ );
diff --git a/openOutpaint-webUI-extension/app/js/ui/tool/dream.d.js b/openOutpaint-webUI-extension/app/js/ui/tool/dream.d.js
new file mode 100644
index 0000000000000000000000000000000000000000..557ea8d18c2c8263842678a5a7033b02057a6f46
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/ui/tool/dream.d.js
@@ -0,0 +1,46 @@
+/**
+ * Stable Diffusion Request
+ *
+ * @typedef StableDiffusionRequest
+ * @property {string} prompt Stable Diffusion prompt
+ * @property {string} negative_prompt Stable Diffusion negative prompt
+ *
+ * @property {number} width Stable Diffusion render width
+ * @property {number} height Stable Diffusion render height
+ *
+ * @property {number} n_iter Stable Diffusion number of iterations
+ * @property {number} batch_size Stable Diffusion images per batches
+ *
+ * @property {number} seed Stable Diffusion seed
+ * @property {number} steps Stable Diffusion step count
+ * @property {number} cfg_scale Stable Diffusion CFG scale
+ * @property {string} sampler_index Stable Diffusion sampler name
+ *
+ * @property {boolean} restore_faces WebUI face restoration
+ * @property {boolean} tiling WebUI tiling
+ * @property {string[]} styles WebUI styles
+ * @property {string} script_name WebUI script name
+ * @property {Array} script_args WebUI script args
+ *
+ * @property {string} mask Stable Diffusion mask (img2img)
+ * @property {number} mask_blur Stable Diffusion mask blur (img2img)
+ *
+ * @property {number} inpainting_fill Stable Diffusion inpainting fill (img2img)
+ * @property {boolean} inpaint_full_res Stable Diffusion full resolution (img2img)
+ */
+
+/**
+ * Stable Diffusion Response
+ *
+ * @typedef StableDiffusionResponse
+ * @property {string[]} images Response images
+ */
+
+/**
+ * Stable Diffusion Progress Response
+ *
+ * @typedef StableDiffusionProgressResponse
+ * @property {number} progress Progress (from 0 to 1)
+ * @property {number} eta_relative Estimated finish time
+ * @property {?string} current_image Progress image
+ */
diff --git a/openOutpaint-webUI-extension/app/js/ui/tool/dream.js b/openOutpaint-webUI-extension/app/js/ui/tool/dream.js
new file mode 100644
index 0000000000000000000000000000000000000000..a337e071bfc6276bf9d91558da6406d9424c77c0
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/ui/tool/dream.js
@@ -0,0 +1,3012 @@
+let blockNewImages = false;
+let generationQueue = [];
+let generationAreas = new Set();
+
+/**
+ * Starts progress monitoring bar
+ *
+ * @param {BoundingBox} bb Bouding Box to draw progress to
+ * @param {(data: object) => void} [oncheck] Callback function for when a progress check returns
+ * @returns {() => void}
+ */
+const _monitorProgress = (bb, oncheck = null) => {
+ const minDelay = 1000;
+
+ const apiURL = `${host}${config.api.path}progress?skip_current_image=true`;
+
+ const expanded = {...bb};
+ expanded.x--;
+ expanded.y--;
+ expanded.w += 2;
+ expanded.h += 2;
+
+ // Get temporary layer to draw progress bar
+ const layer = imageCollection.registerLayer(null, {
+ bb: expanded,
+ category: "display",
+ });
+ layer.canvas.style.opacity = "70%";
+
+ let running = true;
+
+ const _checkProgress = async () => {
+ const init = performance.now();
+
+ try {
+ const response = await fetch(apiURL);
+ /** @type {StableDiffusionProgressResponse} */
+ const data = await response.json();
+
+ oncheck && oncheck(data);
+
+ layer.clear();
+
+ // Draw Progress Bar
+ layer.ctx.fillStyle = "#5F5";
+ layer.ctx.fillRect(1, 1, bb.w * data.progress, 10);
+
+ // Draw Progress Text
+ layer.ctx.fillStyle = "#FFF";
+
+ layer.ctx.fillRect(0, 15, 60, 25);
+ layer.ctx.fillRect(bb.w - 58, 15, 60, 25);
+
+ layer.ctx.font = "20px Open Sans";
+ layer.ctx.fillStyle = "#000";
+ layer.ctx.textAlign = "right";
+ layer.ctx.fillText(`${Math.round(data.progress * 100)}%`, 55, 35);
+
+ // Draw ETA Text
+ layer.ctx.fillText(`${Math.round(data.eta_relative)}s`, bb.w - 5, 35);
+ } finally {
+ }
+
+ const timeSpent = performance.now() - init;
+ setTimeout(
+ () => {
+ if (running) _checkProgress();
+ },
+ Math.max(0, minDelay - timeSpent)
+ );
+ };
+
+ _checkProgress();
+
+ return () => {
+ imageCollection.deleteLayer(layer);
+ running = false;
+ };
+};
+
+let busy = false;
+const generating = (val) => {
+ busy = val;
+ if (busy) {
+ window.onbeforeunload = async () => {
+ await sendInterrupt();
+ };
+ } else {
+ window.onbeforeunload = null;
+ }
+};
+
+/**
+ * Starts a dream
+ *
+ * @param {"txt2img" | "img2img"} endpoint Endpoint to send the request to
+ * @param {StableDiffusionRequest} request Stable diffusion request
+ * @param {BoundingBox} bb Optional: Generated image placement location
+ * @returns {Promise}
+ */
+const _dream = async (endpoint, request, bb = null) => {
+ var bgImg = null;
+ if (
+ endpoint == "img2img" &&
+ bb &&
+ toolbar._current_tool.state.removeBackground
+ ) {
+ bgImg = uil.getVisible(bb, {includeBg: false});
+ }
+
+ const apiURL = `${host}${config.api.path}${endpoint}`;
+ // if script fields are populated add them to the request
+ var scriptName = document.getElementById("script-name-input").value;
+ var scriptArgs = document.getElementById("script-args-input").value;
+ if (scriptName.trim() != "" && scriptArgs.trim() != "") {
+ //TODO add some error handling and stuff?
+ request.script_name = scriptName.trim();
+ // This is necessary so types can be properly specified
+ request.script_args = JSON.parse(scriptArgs.trim() || "[]");
+ }
+
+ // Debugging is enabled
+ if (global.debug) {
+ // Run in parallel
+ (async () => {
+ // Create canvas
+ const canvas = document.createElement("canvas");
+ canvas.width = request.width;
+ canvas.height = request.height * (request.init_images.length + 1);
+ const ctx = canvas.getContext("2d");
+
+ // Load images and draw to canvas
+ for (let i = 0; i < request.init_images.length; i++) {
+ try {
+ const image = document.createElement("img");
+ image.src = request.init_images[i];
+ await image.decode();
+
+ ctx.drawImage(image, 0, i * request.height);
+ } catch (e) {}
+ }
+
+ // Load mask and draw to canvas
+ if (request.mask) {
+ try {
+ const mask = document.createElement("img");
+ mask.src = request.mask;
+ await mask.decode();
+
+ ctx.drawImage(mask, 0, canvas.height - request.height);
+ } catch (e) {}
+ }
+
+ downloadCanvas({
+ canvas,
+ cropToContent: false,
+ filename: `openOutpaint_debug_${new Date()}.png`,
+ });
+ })();
+ }
+
+ /** @type {StableDiffusionResponse} */
+ let data = null;
+ try {
+ generating(true);
+
+ const response = await fetch(apiURL, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(request),
+ });
+
+ data = await response.json();
+ } finally {
+ generating(false);
+ }
+ var responseSubdata = JSON.parse(data.info);
+ console.debug(responseSubdata);
+ var returnData = {
+ images: data.images,
+ seeds: responseSubdata.all_seeds,
+ bgImg: bgImg,
+ };
+ return returnData;
+};
+
+/**
+ * Generate and pick an image for placement
+ *
+ * @param {"txt2img" | "img2img"} endpoint Endpoint to send the request to
+ * @param {StableDiffusionRequest} request Stable diffusion request
+ * @param {BoundingBox} bb Generated image placement location
+ * @param {object} options Options
+ * @param {number} [options.drawEvery=0.2 / request.n_iter] Percentage delta to draw progress at (by default 20% of each iteration)
+ * @param {HTMLCanvasElement} [options.keepUnmask=null] Whether to force keep image under fully opaque mask
+ * @param {number} [options.keepUnmaskBlur=0] Blur when applying full resolution back to the image
+ * @returns {Promise}
+ */
+const _generate = async (endpoint, request, bb, options = {}) => {
+ var alertCount = 0;
+ defaultOpt(options, {
+ drawEvery: 0.2 / request.n_iter,
+ keepUnmask: null,
+ keepUnmaskBlur: 0,
+ });
+
+ events.tool.dream.emit({event: "generate", request});
+
+ const requestCopy = JSON.parse(JSON.stringify(request));
+
+ // Block requests to identical areas
+ const areaid = `${bb.x}-${bb.y}-${bb.w}-${bb.h}`;
+ if (generationAreas.has(areaid)) return;
+ generationAreas.add(areaid);
+
+ // Await for queue
+ let cancelled = false;
+ const waitQueue = async () => {
+ const stopQueueMarchingAnts = march(bb, {style: "#AAF"});
+
+ // Add cancel Button
+ const cancelButton = makeElement("button", bb.x + bb.w - 100, bb.y + bb.h);
+ cancelButton.classList.add("dream-stop-btn");
+ cancelButton.textContent = "Cancel";
+ cancelButton.addEventListener("click", () => {
+ cancelled = true;
+ imageCollection.inputElement.removeChild(cancelButton);
+ stopQueueMarchingAnts();
+ });
+ imageCollection.inputElement.appendChild(cancelButton);
+
+ let qPromise = null;
+ let qResolve = null;
+ await new Promise((finish) => {
+ // Will be this request's (kind of) semaphore
+ qPromise = new Promise((r) => (qResolve = r));
+ generationQueue.push(qPromise);
+
+ // Wait for last generation to end
+ if (generationQueue.length > 1) {
+ (async () => {
+ await generationQueue[generationQueue.length - 2];
+ finish();
+ })();
+ } else {
+ // If this is the first, just continue
+ finish();
+ }
+ });
+ if (!cancelled) {
+ imageCollection.inputElement.removeChild(cancelButton);
+ stopQueueMarchingAnts();
+ }
+
+ return {promise: qPromise, resolve: qResolve};
+ };
+
+ const nextQueue = (queueEntry) => {
+ const generationIndex = generationQueue.findIndex(
+ (v) => v === queueEntry.promise
+ );
+ generationQueue.splice(generationIndex, 1);
+ queueEntry.resolve();
+ };
+
+ const initialQ = await waitQueue();
+
+ if (cancelled) {
+ nextQueue(initialQ);
+ return;
+ }
+
+ // Save masked content
+ let keepUnmaskCanvas = null;
+ let keepUnmaskCtx = null;
+
+ if (options.keepUnmask) {
+ const visibleCanvas = uil.getVisible({
+ x: bb.x - options.keepUnmaskBlur,
+ y: bb.y - options.keepUnmaskBlur,
+ w: bb.w + 2 * options.keepUnmaskBlur,
+ h: bb.h + 2 * options.keepUnmaskBlur,
+ });
+ const visibleCtx = visibleCanvas.getContext("2d");
+
+ const ctx = options.keepUnmask.getContext("2d", {willReadFrequently: true});
+
+ // Save current image
+ keepUnmaskCanvas = document.createElement("canvas");
+ keepUnmaskCanvas.width = options.keepUnmask.width;
+ keepUnmaskCanvas.height = options.keepUnmask.height;
+
+ keepUnmaskCtx = keepUnmaskCanvas.getContext("2d", {
+ willReadFrequently: true,
+ });
+
+ if (
+ visibleCanvas.width !==
+ keepUnmaskCanvas.width + 2 * options.keepUnmaskBlur ||
+ visibleCanvas.height !==
+ keepUnmaskCanvas.height + 2 * options.keepUnmaskBlur
+ ) {
+ throw new Error(
+ "[dream] Provided mask is not the same size as the bounding box"
+ );
+ }
+
+ // Cut out changing elements
+ const blurMaskCanvas = document.createElement("canvas");
+ // A bit bigger to handle literal corner cases
+ blurMaskCanvas.width = bb.w + options.keepUnmaskBlur * 2;
+ blurMaskCanvas.height = bb.h + options.keepUnmaskBlur * 2;
+ const blurMaskCtx = blurMaskCanvas.getContext("2d");
+
+ const blurMaskData = blurMaskCtx.getImageData(
+ options.keepUnmaskBlur,
+ options.keepUnmaskBlur,
+ keepUnmaskCanvas.width,
+ keepUnmaskCanvas.height
+ );
+
+ const image = blurMaskData.data;
+
+ const maskData = ctx.getImageData(
+ 0,
+ 0,
+ options.keepUnmask.width,
+ options.keepUnmask.height
+ );
+
+ const mask = maskData.data;
+
+ for (let i = 0; i < mask.length; i += 4) {
+ if (mask[i] !== 0 || mask[i + 1] !== 0 || mask[i + 2] !== 0) {
+ // If pixel is fully black
+ // Set pixel as fully black here as well
+ image[i] = 0;
+ image[i + 1] = 0;
+ image[i + 2] = 0;
+ image[i + 3] = 255;
+ }
+ }
+
+ blurMaskCtx.putImageData(
+ blurMaskData,
+ options.keepUnmaskBlur,
+ options.keepUnmaskBlur
+ );
+
+ visibleCtx.filter = `blur(${options.keepUnmaskBlur}px)`;
+ visibleCtx.globalCompositeOperation = "destination-out";
+ visibleCtx.drawImage(blurMaskCanvas, 0, 0);
+
+ keepUnmaskCtx.drawImage(
+ visibleCanvas,
+ -options.keepUnmaskBlur,
+ -options.keepUnmaskBlur
+ );
+ }
+
+ // Images to select through
+ let at = 0;
+ /** @type {Array} */
+ const images = [null];
+ const seeds = [-1];
+ const markedImages = [null]; //A sparse array of booleans indicating which images have been marked, by index
+ /** @type {HTMLDivElement} */
+ let imageSelectMenu = null;
+ // Layer for the images
+ const layer = imageCollection.registerLayer(null, {
+ after: maskPaintLayer,
+ category: "display",
+ });
+
+ const redraw = (url = images[at]) => {
+ if (url === null) layer.clear();
+ if (!url) return;
+
+ const img = new Image();
+ img.src = "data:image/png;base64," + url;
+ img.addEventListener("load", () => {
+ const canvas = document.createElement("canvas");
+ canvas.width = bb.w;
+ canvas.height = bb.h;
+
+ // Creates new canvas for blurred mask
+ const blurMaskCanvas = document.createElement("canvas");
+ blurMaskCanvas.width = bb.w + options.keepUnmaskBlur * 2;
+ blurMaskCanvas.height = bb.h + options.keepUnmaskBlur * 2;
+
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, bb.w, bb.h);
+
+ if (keepUnmaskCanvas) {
+ ctx.drawImage(keepUnmaskCanvas, 0, 0);
+ }
+
+ layer.clear();
+ layer.ctx.drawImage(
+ canvas,
+ 0,
+ 0,
+ canvas.width,
+ canvas.height,
+ bb.x,
+ bb.y,
+ bb.w,
+ bb.h
+ );
+ });
+ };
+
+ const sendInterrupt = () => {
+ fetch(`${host}${config.api.path}interrupt`, {method: "POST"});
+ };
+
+ // Add Interrupt Button
+ const interruptButton = makeElement("button", bb.x + bb.w - 100, bb.y + bb.h);
+ interruptButton.classList.add("dream-stop-btn");
+ interruptButton.textContent = "Interrupt";
+ interruptButton.addEventListener("click", () => {
+ sendInterrupt();
+ interruptButton.disabled = true;
+ });
+ const marchingOptions = {};
+ const stopMarchingAnts = march(bb, marchingOptions);
+
+ // First Dream Run
+ console.info(`[dream] Generating images for prompt '${request.prompt}'`);
+ console.debug(request);
+
+ eagerGenerateCount = toolbar._current_tool.state.eagerGenerateCount;
+ isDreamComplete = false;
+
+ let stopProgress = null;
+ try {
+ let stopDrawingStatus = false;
+ let lastProgress = 0;
+ let nextCP = options.drawEvery;
+ stopProgress = _monitorProgress(bb, (data) => {
+ if (stopDrawingStatus) return;
+
+ if (lastProgress < nextCP && data.progress >= nextCP) {
+ nextCP += options.drawEvery;
+ fetch(
+ `${host}${config.api.path}progress?skip_current_image=false`
+ ).then(async (response) => {
+ if (stopDrawingStatus) return;
+ const imagedata = await response.json();
+ redraw(imagedata.current_image);
+ });
+ }
+ lastProgress = data.progress;
+ });
+
+ imageCollection.inputElement.appendChild(interruptButton);
+ var dreamData = await _dream(endpoint, requestCopy, bb);
+ images.push(...dreamData.images);
+ seeds.push(...dreamData.seeds);
+ stopDrawingStatus = true;
+ at = 1;
+ } catch (e) {
+ notifications.notify(
+ `Error generating images. Please try again or see console for more details`,
+ {
+ type: NotificationType.ERROR,
+ timeout: config.notificationTimeout * 2,
+ }
+ );
+ console.warn(`[dream] Error generating images:`);
+ console.warn(e);
+ } finally {
+ stopProgress();
+ imageCollection.inputElement.removeChild(interruptButton);
+ }
+
+ const needMoreGenerations = () => {
+ return (
+ eagerGenerateCount > 0 &&
+ images.length - highestNavigatedImageIndex <= eagerGenerateCount
+ );
+ };
+
+ const isGenerationPending = () => {
+ return generationQueue.length > 0;
+ };
+
+ let highestNavigatedImageIndex = 0;
+
+ // Image navigation
+ const prevImg = () => {
+ at--;
+ if (at < 0) at = images.length - 1;
+
+ activateImgAt(at);
+ };
+
+ const prevImgEvent = (evn) => {
+ if (evn.shiftKey) {
+ prevMarkedImg();
+ } else {
+ prevImg();
+ }
+ };
+
+ const nextImg = () => {
+ at++;
+ if (at >= images.length) at = 0;
+
+ highestNavigatedImageIndex = Math.max(at, highestNavigatedImageIndex);
+
+ activateImgAt(at);
+
+ if (needMoreGenerations() && !isGenerationPending()) {
+ makeMore();
+ }
+ };
+
+ const nextImgEvent = (evn) => {
+ if (evn.shiftKey) {
+ nextMarkedImg();
+ } else {
+ nextImg();
+ }
+ };
+
+ const activateImgAt = (at) => {
+ updateImageIndexText();
+ var seed = seeds[at];
+ seedbtn.title = "Use seed " + seed;
+ redraw();
+ };
+
+ const applyImg = async () => {
+ if (!images[at]) return;
+
+ const img = new Image();
+ // load the image data after defining the closure
+ img.src = "data:image/png;base64," + images[at];
+ img.addEventListener("load", () => {
+ let canvas = document.createElement("canvas");
+ canvas.width = bb.w;
+ canvas.height = bb.h;
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, bb.w, bb.h);
+
+ if (keepUnmaskCanvas) {
+ ctx.drawImage(keepUnmaskCanvas, 0, 0);
+ }
+
+ if (localStorage.getItem("openoutpaint/settings.autolayer") == "true") {
+ commands.runCommand("addLayer", "Added Layer", {});
+ }
+
+ if (
+ endpoint == "img2img" &&
+ toolbar._current_tool.state.removeBackground
+ ) {
+ canvas = subtractBackground(
+ canvas,
+ bb,
+ dreamData.bgImg,
+ toolbar._current_tool.state.carve_blur,
+ toolbar._current_tool.state.carve_threshold
+ );
+ }
+
+ let commandLog = "";
+
+ const addline = (v, newline = true) => {
+ commandLog += v;
+ if (newline) commandLog += "\n";
+ };
+
+ addline(
+ `Dreamed image at x: ${bb.x}, y: ${bb.y}, w: ${bb.w}, h: ${bb.h}`
+ );
+ addline(` - resolution: (${request.width}, ${request.height})`);
+ addline(" - generation:");
+ addline(` + Seed = ${seeds[at]}`);
+ addline(` + Steps = ${request.steps}`);
+ addline(` + CFG = ${request.cfg_scale}`);
+ addline(` + Sampler = ${request.sampler_index}`);
+ addline(` + Model = ${modelAutoComplete.value}`);
+ addline(` + +Prompt = ${request.prompt}`);
+ addline(` + -Prompt = ${request.negative_prompt}`);
+ addline(` + Styles = ${request.styles.join(", ")}`, false);
+
+ commands.runCommand(
+ "drawImage",
+ "Image Dream",
+ {
+ x: bb.x,
+ y: bb.y,
+ w: bb.w,
+ h: bb.h,
+ image: canvas,
+ },
+ {
+ extra: {
+ log: commandLog,
+ },
+ }
+ );
+ clean(!toolbar._current_tool.state.preserveMasks);
+ });
+ };
+
+ const removeImg = async () => {
+ if (!images[at]) return;
+ images.splice(at, 1);
+ seeds.splice(at, 1);
+ markedImages.splice(at, 1);
+ if (at > images.length - 1) prevImg();
+ if (images.length - 1 === 0) discardImg();
+ updateImageIndexText();
+ var seed = seeds[at];
+ seedbtn.title = "Use seed " + seed;
+ redraw();
+ };
+
+ const toggleMarkedImg = async () => {
+ markedImages[at] = markedImages[at] == true ? null : true;
+ activateImgAt(at); //redraw just to update caption
+ };
+
+ const nextMarkedImg = () => {
+ var nextIndex = getNextMarkedImage(at);
+ if (nextIndex == null) {
+ //If no next marked image, and we're not currently on a marked image, then return the last marked image in the list, if any, rather than doing nothing
+ if (markedImages[at] == true) {
+ return;
+ } else {
+ nextIndex = getPrevMarkedImage(at);
+ if (nextIndex == null) {
+ return;
+ }
+ }
+ }
+ at = nextIndex;
+ activateImgAt(at);
+ };
+
+ const prevMarkedImg = () => {
+ var nextIndex = getPrevMarkedImage(at);
+ if (nextIndex == null) {
+ //If no previous marked image, and we're not currently on a marked image, then return the next image in the list, if any, rather than doing nothing
+ if (markedImages[at] == true) {
+ return;
+ } else {
+ nextIndex = getNextMarkedImage(at);
+ if (nextIndex == null) {
+ return;
+ }
+ }
+ }
+ at = nextIndex;
+ activateImgAt(at);
+ };
+
+ const getNextMarkedImage = (at) => {
+ for (let i = at + 1; i < markedImages.length; i++) {
+ if (markedImages[i] != null) {
+ return i;
+ }
+ }
+ return null;
+ };
+
+ const getPrevMarkedImage = (at) => {
+ for (let i = at - 1; i >= 0; --i) {
+ if (markedImages[i] != null) {
+ return i;
+ }
+ }
+ return null;
+ };
+
+ const updateImageIndexText = () => {
+ var markedImageIndicator = markedImages[at] == true ? "*" : "";
+ imageindextxt.textContent = `${markedImageIndicator}${at}/${
+ images.length - 1
+ }`;
+ };
+
+ const makeMore = async () => {
+ const moreQ = await waitQueue();
+ try {
+ stopProgress = _monitorProgress(bb);
+ interruptButton.disabled = false;
+ imageCollection.inputElement.appendChild(interruptButton);
+ if (requestCopy.seed != -1) {
+ requestCopy.seed =
+ parseInt(requestCopy.seed) +
+ requestCopy.batch_size * requestCopy.n_iter;
+ }
+
+ if (
+ localStorage.getItem(
+ "openoutpaint/settings.update-prompt-on-more-button"
+ ) == "true"
+ ) {
+ requestCopy.prompt = document.getElementById("prompt").value;
+ requestCopy.negative_prompt =
+ document.getElementById("negPrompt").value;
+ }
+ dreamData = await _dream(endpoint, requestCopy);
+ images.push(...dreamData.images);
+ seeds.push(...dreamData.seeds);
+ if (
+ localStorage.getItem(
+ "openoutpaint/settings.jump-to-1st-new-on-more-button"
+ ) == "true"
+ ) {
+ at = images.length - requestCopy.n_iter * requestCopy.batch_size;
+ activateImgAt(at);
+ } else {
+ updateImageIndexText();
+ }
+ } catch (e) {
+ if (alertCount < 2) {
+ notifications.notify(
+ `Error generating images. Please try again or see console for more details`,
+ {
+ type: NotificationType.ERROR,
+ timeout: config.notificationTimeout * 2,
+ }
+ );
+ } else {
+ eagerGenerateCount = 0;
+ }
+ alertCount++;
+ console.warn(`[dream] Error generating images:`);
+ console.warn(e);
+ } finally {
+ stopProgress();
+ imageCollection.inputElement.removeChild(interruptButton);
+ }
+
+ nextQueue(moreQ);
+
+ //Start the next batch if we're eager-generating
+ if (needMoreGenerations() && !isGenerationPending() && !isDreamComplete) {
+ makeMore();
+ }
+ };
+
+ const discardImg = async () => {
+ clean();
+ };
+
+ const saveImg = async () => {
+ if (!images[at]) return;
+
+ const img = new Image();
+ // load the image data after defining the closure
+ img.src = "data:image/png;base64," + images[at];
+ img.addEventListener("load", () => {
+ const canvas = document.createElement("canvas");
+ canvas.width = img.width;
+ canvas.height = img.height;
+ canvas.getContext("2d").drawImage(img, 0, 0);
+
+ downloadCanvas({
+ canvas,
+ filename: `openOutpaint - dream - ${request.prompt} - ${at}.png`,
+ });
+ });
+ };
+
+ // Listen for keyboard arrows
+ const onarrow = (evn) => {
+ switch (evn.target.tagName.toLowerCase()) {
+ case "input":
+ case "textarea":
+ case "select":
+ case "button":
+ return; // If in an input field, do not process arrow input
+ default:
+ // Do nothing
+ break;
+ }
+
+ switch (evn.key) {
+ case "+":
+ makeMore();
+ break;
+ case "-":
+ removeImg();
+ break;
+ case "*":
+ toggleMarkedImg();
+
+ default:
+ switch (evn.code) {
+ case "ArrowRight":
+ nextImgEvent(evn.evn);
+ break;
+ case "ArrowLeft":
+ prevImgEvent(evn.evn);
+ break;
+ case "Enter":
+ applyImg();
+ break;
+ case "Escape":
+ discardImg();
+ break;
+ default:
+ break;
+ }
+ break;
+ }
+ };
+
+ keyboard.listen.onkeyclick.on(onarrow);
+
+ // For handling mouse events for navigation
+ const onmovehandler = mouse.listen.world.onmousemove.on(
+ (evn, state) => {
+ const contains = bb.contains(evn.x, evn.y);
+
+ if (!contains && !state.dream_processed) {
+ imageCollection.inputElement.style.cursor = "auto";
+ toolbar._current_tool.state.block_res_change = false;
+ }
+ if (!contains || state.dream_processed) {
+ marchingOptions.style = "#FFF";
+ toolbar._current_tool.state.block_res_change = false;
+ }
+ if (!state.dream_processed && contains) {
+ marchingOptions.style = "#F55";
+
+ imageCollection.inputElement.style.cursor = "pointer";
+
+ state.dream_processed = true;
+ toolbar._current_tool.state.block_res_change = true;
+ }
+ },
+ 0,
+ true
+ );
+
+ const onclickhandler = mouse.listen.world.btn.left.onclick.on(
+ (evn, state) => {
+ if (!state.dream_processed && bb.contains(evn.x, evn.y)) {
+ applyImg();
+ imageCollection.inputElement.style.cursor = "auto";
+ state.dream_processed = true;
+ }
+ },
+ 1,
+ true
+ );
+ const oncancelhandler = mouse.listen.world.btn.right.onclick.on(
+ (evn, state) => {
+ if (!state.dream_processed && bb.contains(evn.x, evn.y)) {
+ if (images.length > 1) {
+ removeImg();
+ } else {
+ discardImg();
+ }
+ imageCollection.inputElement.style.cursor = "auto";
+ state.dream_processed = true;
+ }
+ },
+ 1,
+ true
+ );
+ const onmorehandler = mouse.listen.world.btn.middle.onclick.on(
+ (evn, state) => {
+ if (!state.dream_processed && bb.contains(evn.x, evn.y)) {
+ makeMore();
+ state.dream_processed = true;
+ }
+ },
+ 1,
+ true
+ );
+ const onwheelhandler = mouse.listen.world.onwheel.on(
+ (evn, state) => {
+ if (!state.dream_processed && bb.contains(evn.x, evn.y)) {
+ if (evn.delta < 0) {
+ nextImgEvent(evn.evn);
+ } else prevImgEvent(evn.evn);
+ state.dream_processed = true;
+ }
+ },
+ 1,
+ true
+ );
+
+ // Cleans up
+ const clean = (removeBrushMask = false) => {
+ if (removeBrushMask) {
+ maskPaintCtx.clearRect(bb.x, bb.y, bb.w, bb.h);
+ }
+ stopMarchingAnts();
+ imageCollection.inputElement.removeChild(imageSelectMenu);
+ imageCollection.deleteLayer(layer);
+ keyboard.listen.onkeyclick.clear(onarrow);
+ // Remove area from no-generate list
+ generationAreas.delete(areaid);
+
+ // Stop handling inputs
+ mouse.listen.world.onmousemove.clear(onmovehandler);
+ mouse.listen.world.btn.left.onclick.clear(onclickhandler);
+ mouse.listen.world.btn.right.onclick.clear(oncancelhandler);
+ mouse.listen.world.btn.middle.onclick.clear(onmorehandler);
+ mouse.listen.world.onwheel.clear(onwheelhandler);
+ isDreamComplete = true;
+ generating(false);
+ };
+
+ redraw();
+
+ imageSelectMenu = makeElement("div", bb.x, bb.y + bb.h);
+
+ const imageindextxt = document.createElement("button");
+ updateImageIndexText();
+
+ imageindextxt.addEventListener("click", () => {
+ at = 0;
+ updateImageIndexText();
+ redraw();
+ });
+
+ const backbtn = document.createElement("button");
+ backbtn.textContent = "<";
+ backbtn.title = "Previous Image";
+ backbtn.addEventListener("click", prevImgEvent);
+ imageSelectMenu.appendChild(backbtn);
+ imageSelectMenu.appendChild(imageindextxt);
+
+ const nextbtn = document.createElement("button");
+ nextbtn.textContent = ">";
+ nextbtn.title = "Next Image";
+ nextbtn.addEventListener("click", nextImgEvent);
+ imageSelectMenu.appendChild(nextbtn);
+
+ const morebtn = document.createElement("button");
+ morebtn.textContent = "+";
+ morebtn.title = "Generate More";
+ morebtn.addEventListener("click", makeMore);
+ imageSelectMenu.appendChild(morebtn);
+
+ const removebtn = document.createElement("button");
+ removebtn.textContent = "-";
+ removebtn.title = "Remove From Batch";
+ removebtn.addEventListener("click", removeImg);
+ imageSelectMenu.appendChild(removebtn);
+
+ const acceptbtn = document.createElement("button");
+ acceptbtn.textContent = "Y";
+ acceptbtn.title = "Apply Current";
+ acceptbtn.addEventListener("click", applyImg);
+ imageSelectMenu.appendChild(acceptbtn);
+
+ const discardbtn = document.createElement("button");
+ discardbtn.textContent = "N";
+ discardbtn.title = "Cancel";
+ discardbtn.addEventListener("click", discardImg);
+ imageSelectMenu.appendChild(discardbtn);
+
+ const resourcebtn = document.createElement("button");
+ resourcebtn.textContent = "R";
+ resourcebtn.title = "Save to Resources";
+ resourcebtn.addEventListener("click", async () => {
+ const img = new Image();
+ // load the image data after defining the closure
+ img.src = "data:image/png;base64," + images[at];
+ img.addEventListener("load", () => {
+ const response = prompt(
+ "Enter new resource name",
+ "Dream Resource " + seeds[at]
+ );
+ if (response) {
+ tools.stamp.state.addResource(response, img);
+ redraw(); // Redraw to avoid strange cursor behavior
+ }
+ });
+ });
+ imageSelectMenu.appendChild(resourcebtn);
+
+ const savebtn = document.createElement("button");
+ savebtn.textContent = "S";
+ savebtn.title = "Download image to computer";
+ savebtn.addEventListener("click", async () => {
+ saveImg();
+ });
+ imageSelectMenu.appendChild(savebtn);
+
+ const seedbtn = document.createElement("button");
+ seedbtn.textContent = "U";
+ seedbtn.title = "Use seed " + `${seeds[at]}`;
+ seedbtn.addEventListener("click", () => {
+ sendSeed(seeds[at]);
+ });
+ imageSelectMenu.appendChild(seedbtn);
+
+ const toggleMarkedButton = document.createElement("button");
+ toggleMarkedButton.textContent = "*";
+ toggleMarkedButton.title = "Mark/Unmark";
+ toggleMarkedButton.addEventListener("click", toggleMarkedImg);
+ imageSelectMenu.appendChild(toggleMarkedButton);
+
+ nextQueue(initialQ);
+
+ //Start the next batch after the initial generation
+ if (needMoreGenerations()) {
+ makeMore();
+ }
+};
+
+/**
+ * Callback for generating a image (dream tool)
+ *
+ * @param {*} evn
+ * @param {*} state
+ */
+const dream_generate_callback = async (bb, resolution, state) => {
+ // Build request to the API
+ const request = {};
+ const canvasTransport = {}; //this is the worst idea but i hate myself so i'm doing it anyway
+ Object.assign(request, stableDiffusionData);
+
+ request.width = resolution.w;
+ request.height = resolution.h;
+
+ // Load prompt (maybe we should add some events so we don't have to do this)
+ request.prompt = document.getElementById("prompt").value;
+ request.negative_prompt = document.getElementById("negPrompt").value;
+
+ // Get visible pixels
+ const visibleCanvas = uil.getVisible(bb);
+
+ if (extensions.alwaysOnScripts) {
+ buildAlwaysOnScripts(state);
+ }
+
+ // Use txt2img if canvas is blank or if controlnet is active because "Allow inpaint in txt2img. This is necessary because txt2img has high-res fix" as per https://github.com/Mikubill/sd-webui-controlnet/discussions/1464
+ if (
+ isCanvasBlank(0, 0, bb.w, bb.h, visibleCanvas) ||
+ (extensions.controlNetActive && toolbar._current_tool.name === "Dream")
+ ) {
+ //TODO why doesn't smooth rendering toggle persist/localstorage? why am i putting this here? because i'm lazy
+
+ //TODO this logic seems crappy, fix it
+ if (!isCanvasBlank(0, 0, bb.w, bb.h, visibleCanvas)) {
+ // get input image
+ // Temporary canvas for init image and mask generation
+ const bbCanvas = document.createElement("canvas");
+ bbCanvas.width = bb.w;
+ bbCanvas.height = bb.h;
+ const bbCtx = bbCanvas.getContext("2d");
+
+ const maskCanvas = document.createElement("canvas");
+ maskCanvas.width = request.width;
+ maskCanvas.height = request.height;
+ const maskCtx = maskCanvas.getContext("2d");
+
+ const initCanvas = document.createElement("canvas");
+ initCanvas.width = request.width;
+ initCanvas.height = request.height;
+ const initCtx = initCanvas.getContext("2d");
+
+ bbCtx.fillStyle = "#000F";
+
+ // Get init image
+ initCtx.fillRect(0, 0, request.width, request.height);
+ initCtx.drawImage(
+ visibleCanvas,
+ 0,
+ 0,
+ bb.w,
+ bb.h,
+ 0,
+ 0,
+ request.width,
+ request.height
+ );
+ // request.init_images = [initCanvas.toDataURL()];
+
+ // Get mask image
+ bbCtx.fillStyle = "#000F";
+ bbCtx.fillRect(0, 0, bb.w, bb.h);
+ if (state.invertMask) {
+ // overmasking by definition is entirely pointless with an inverted mask outpaint
+ // since it should explicitly avoid brushed masks too, we just won't even bother
+ bbCtx.globalCompositeOperation = "destination-in";
+ bbCtx.drawImage(
+ maskPaintCanvas,
+ bb.x,
+ bb.y,
+ bb.w,
+ bb.h,
+ 0,
+ 0,
+ bb.w,
+ bb.h
+ );
+
+ bbCtx.globalCompositeOperation = "destination-in";
+ bbCtx.drawImage(visibleCanvas, 0, 0);
+ } else {
+ bbCtx.globalCompositeOperation = "destination-in";
+ bbCtx.drawImage(visibleCanvas, 0, 0);
+ // here's where to overmask to avoid including the brushed mask
+ // 99% of my issues were from failing to set source-over for the overmask blotches
+ if (state.overMaskPx > 0) {
+ // transparent to white first
+ bbCtx.globalCompositeOperation = "destination-atop";
+ bbCtx.fillStyle = "#FFFF";
+ bbCtx.fillRect(0, 0, bb.w, bb.h);
+ applyOvermask(bbCanvas, bbCtx, state.overMaskPx);
+ }
+
+ bbCtx.globalCompositeOperation = "destination-out"; // ???
+ bbCtx.drawImage(
+ maskPaintCanvas,
+ bb.x,
+ bb.y,
+ bb.w,
+ bb.h,
+ 0,
+ 0,
+ bb.w,
+ bb.h
+ );
+ }
+
+ bbCtx.globalCompositeOperation = "destination-atop";
+ bbCtx.fillStyle = "#FFFF";
+ bbCtx.fillRect(0, 0, bb.w, bb.h);
+
+ maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
+ maskCtx.drawImage(
+ bbCanvas,
+ 0,
+ 0,
+ bb.w,
+ bb.h,
+ 0,
+ 0,
+ request.width,
+ request.height
+ );
+ canvasTransport.initCanvas = initCanvas;
+ canvasTransport.maskCanvas = maskCanvas;
+
+ // getImageAndMask(visibleCanvas, bb, request, state); //FFFFF
+ }
+
+ // request.alwayson_scripts = state.alwayson_scripts;
+
+ if (!global.isOldHRFix && request.enable_hr) {
+ /**
+ * try and make the new HRfix method useful for our purposes
+ */
+ // laziness convenience
+ let lockpx = stableDiffusionData.hr_fix_lock_px;
+ if (lockpx > 0) {
+ // find the most appropriate scale factor for hrfix
+ var widthFactor =
+ request.width / lockpx <= 4 ? request.width / lockpx : 4;
+ var heightFactor =
+ request.height / lockpx <= 4 ? request.height / lockpx : 4;
+ var factor = heightFactor > widthFactor ? heightFactor : widthFactor;
+ request.hr_scale = hrFixScaleSlider.value = factor < 1 ? 1 : factor;
+ }
+ // moar laziness convenience
+ var divW = Math.floor(request.width / request.hr_scale);
+ var divH = Math.floor(request.height / request.hr_scale);
+
+ if (localStorage.getItem("openoutpaint/settings.hrfix-liar") == "true") {
+ /**
+ * since it now returns an image that's been upscaled x the hr_scale parameter,
+ * we cheekily lie to SD and tell it that the original dimensions are _divided_
+ * by the scale factor so it returns something about the same size as we wanted initially
+ */
+ var firstpassWidth = divW;
+ var firstpassHeight = divH; // liar's firstpass output resolution
+ var desiredWidth = request.width;
+ var desiredHeight = request.height; // truthful desired output resolution
+ } else {
+ // use scale normally, dump supersampled image into undersized reticle
+ var desiredWidth = request.width * request.hr_scale;
+ var desiredHeight = request.height * request.hr_scale; //desired 2nd-pass output resolution
+ var firstpassWidth = request.width;
+ var firstpassHeight = request.height;
+ }
+
+ // ensure firstpass "resolution" complies with lockpx
+ if (lockpx > 0) {
+ //sigh repeated loop
+ firstpassWidth = divW < lockpx ? divW : lockpx;
+ firstpassHeight = divH < lockpx ? divH : lockpx;
+ }
+
+ if (stableDiffusionData.hr_square_aspect) {
+ larger =
+ firstpassWidth > firstpassHeight ? firstpassWidth : firstpassHeight;
+ firstpassWidth = firstpassHeight = larger;
+ }
+ request.width = firstpassWidth;
+ request.height = firstpassHeight;
+ request.hr_resize_x = desiredWidth;
+ request.hr_resize_y = desiredHeight;
+ }
+
+ // For compatibility with the old HRFix API
+ if (global.isOldHRFix && request.enable_hr) {
+ // For compatibility with the old HRFix API
+ request.firstphase_width = request.width / 2;
+ request.firstphase_height = request.height / 2;
+ }
+
+ // Only set this if HRFix is enabled in the first place
+ request.denoising_strength =
+ !global.isOldHRFix && request.enable_hr
+ ? stableDiffusionData.hr_denoising_strength
+ : 1;
+
+ // add dynamic prompts stuff if it exists because it needs to be explicitly disabled if we don't want it
+ if (extensions.dynamicPromptsEnabled) {
+ addDynamicPromptsToAlwaysOnScripts(state);
+ }
+ if (
+ extensions.controlNetActive &&
+ !isCanvasBlank(0, 0, bb.w, bb.h, visibleCanvas)
+ ) {
+ addControlNetToAlwaysOnScripts(
+ state,
+ canvasTransport.initCanvas,
+ canvasTransport.maskCanvas
+ );
+ } else if (extensions.controlNetActive) {
+ console.warn(
+ "[dream] controlnet inpaint/outpaint enabled for null image, defaulting to normal txt2img dream"
+ );
+ }
+
+ if (extensions.alwaysOnScripts) {
+ // check again just to be sure because i'm an idiot?
+ // addControlNetToAlwaysOnScripts(state);
+ // addDynamicPromptsToAlwaysOnScripts(state);
+ request.alwayson_scripts = state.alwayson_scripts;
+ }
+ // Dream
+ _generate("txt2img", request, bb);
+ } else {
+ // Use img2img if not
+
+ // Temporary canvas for init image and mask generation
+ const bbCanvas = document.createElement("canvas");
+ bbCanvas.width = bb.w;
+ bbCanvas.height = bb.h;
+ const bbCtx = bbCanvas.getContext("2d");
+
+ const maskCanvas = document.createElement("canvas");
+ maskCanvas.width = request.width;
+ maskCanvas.height = request.height;
+ const maskCtx = maskCanvas.getContext("2d");
+
+ const initCanvas = document.createElement("canvas");
+ initCanvas.width = request.width;
+ initCanvas.height = request.height;
+ const initCtx = initCanvas.getContext("2d");
+
+ bbCtx.fillStyle = "#000F";
+
+ // Get init image
+ initCtx.fillRect(0, 0, request.width, request.height);
+ initCtx.drawImage(
+ visibleCanvas,
+ 0,
+ 0,
+ bb.w,
+ bb.h,
+ 0,
+ 0,
+ request.width,
+ request.height
+ );
+ request.init_images = [initCanvas.toDataURL()];
+
+ // Get mask image
+ bbCtx.fillStyle = "#000F";
+ bbCtx.fillRect(0, 0, bb.w, bb.h);
+ if (state.invertMask) {
+ // overmasking by definition is entirely pointless with an inverted mask outpaint
+ // since it should explicitly avoid brushed masks too, we just won't even bother
+ bbCtx.globalCompositeOperation = "destination-in";
+ bbCtx.drawImage(
+ maskPaintCanvas,
+ bb.x,
+ bb.y,
+ bb.w,
+ bb.h,
+ 0,
+ 0,
+ bb.w,
+ bb.h
+ );
+
+ bbCtx.globalCompositeOperation = "destination-in";
+ bbCtx.drawImage(visibleCanvas, 0, 0);
+ } else {
+ bbCtx.globalCompositeOperation = "destination-in";
+ bbCtx.drawImage(visibleCanvas, 0, 0);
+ // here's where to overmask to avoid including the brushed mask
+ // 99% of my issues were from failing to set source-over for the overmask blotches
+ if (state.overMaskPx > 0) {
+ // transparent to white first
+ bbCtx.globalCompositeOperation = "destination-atop";
+ bbCtx.fillStyle = "#FFFF";
+ bbCtx.fillRect(0, 0, bb.w, bb.h);
+ applyOvermask(bbCanvas, bbCtx, state.overMaskPx);
+ }
+
+ bbCtx.globalCompositeOperation = "destination-out"; // ???
+ bbCtx.drawImage(
+ maskPaintCanvas,
+ bb.x,
+ bb.y,
+ bb.w,
+ bb.h,
+ 0,
+ 0,
+ bb.w,
+ bb.h
+ );
+ }
+
+ bbCtx.globalCompositeOperation = "destination-atop";
+ bbCtx.fillStyle = "#FFFF";
+ bbCtx.fillRect(0, 0, bb.w, bb.h);
+
+ maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
+ maskCtx.drawImage(
+ bbCanvas,
+ 0,
+ 0,
+ bb.w,
+ bb.h,
+ 0,
+ 0,
+ request.width,
+ request.height
+ );
+ // getImageAndMask(visibleCanvas, bb, request, state); // why is not working ffff
+ request.mask = maskCanvas.toDataURL();
+ request.inpainting_fill = stableDiffusionData.outpainting_fill;
+ request.image_cfg_scale = stableDiffusionData.image_cfg_scale;
+
+ // add dynamic prompts stuff if it's enabled
+ if (extensions.dynamicPromptsEnabled) {
+ addDynamicPromptsToAlwaysOnScripts(state);
+ }
+ if (extensions.controlNetActive) {
+ addControlNetToAlwaysOnScripts(state, initCanvas, maskCanvas);
+ }
+ if (extensions.alwaysOnScripts) {
+ // check again just to be sure because i'm an idiot?
+ // addControlNetToAlwaysOnScripts(state);
+ // addDynamicPromptsToAlwaysOnScripts(state);
+ request.alwayson_scripts = state.alwayson_scripts;
+ }
+
+ // Dream
+ _generate("img2img", request, bb, {
+ keepUnmask: state.keepUnmasked ? bbCanvas : null,
+ keepUnmaskBlur: state.keepUnmaskedBlur,
+ });
+ }
+};
+
+/**
+ * Erases an area from the canvas
+ *
+ * @param {BoundingBox} bb Bounding box of the area to be erased
+ */
+const dream_erase_callback = (bb) => {
+ commands.runCommand("eraseImage", "Erase Area", bb, {
+ extra: {
+ log: `Erased area at x: ${bb.x}, y: ${bb.y}, width: ${bb.w}, height: ${bb.h}`,
+ },
+ });
+};
+
+function applyOvermask(canvas, ctx, px) {
+ // :badpokerface: look it might be all placebo but i like overmask lol
+ // yes it's crushingly inefficient i knooow :( must fix
+ // https://stackoverflow.com/a/30204783 was instrumental to this working or completely to blame for this disaster depending on your interpretation
+ ctx.globalCompositeOperation = "source-over";
+ var ctxImgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
+ for (i = 0; i < ctxImgData.data.length; i += 4) {
+ if (ctxImgData.data[i] == 255) {
+ // white pixel?
+ // just blotch all over the thing
+ /**
+ * This should probably have a better randomness profile for the overmasking
+ *
+ * Essentially, we want to have much more smaller values for randomness than big ones,
+ * because big values overshadow smaller circles and kinda ignores their randomness.
+ *
+ * And also, we want the profile to become more extreme the bigger the overmask size,
+ * because bigger px values also make bigger circles ocuppy more horizontal space.
+ */
+ let lowRandom =
+ Math.atan(Math.random() * 10 - 10) / Math.abs(Math.atan(-10)) + 1;
+ lowRandom = Math.pow(lowRandom, px / 8);
+
+ var rando = Math.floor(lowRandom * px);
+ ctx.beginPath();
+ ctx.arc(
+ (i / 4) % canvas.width,
+ Math.floor(i / 4 / canvas.width),
+ rando, // was 4 * sf + rando, too big, but i think i want it more ... random
+ 0,
+ 2 * Math.PI,
+ true
+ );
+ ctx.fillStyle = "#FFFF";
+ ctx.fill();
+ }
+ }
+}
+
+/**
+ * Image to Image
+ */
+const dream_img2img_callback = (bb, resolution, state) => {
+ // Get visible pixels
+ const visibleCanvas = uil.getVisible(bb);
+
+ if (extensions.alwaysOnScripts) {
+ buildAlwaysOnScripts(state);
+ }
+
+ // Do nothing if no image exists
+ if (isCanvasBlank(0, 0, bb.w, bb.h, visibleCanvas)) return;
+
+ // Build request to the API
+ const request = {};
+ Object.assign(request, stableDiffusionData);
+
+ request.width = resolution.w;
+ request.height = resolution.h;
+
+ request.denoising_strength = state.denoisingStrength;
+ request.inpainting_fill = state.inpainting_fill ?? 1; //let's see how this works //1; // For img2img use original
+ request.image_cfg_scale = state.image_cfg_scale ?? 0.5; // what am i even doing
+
+ // Load prompt (maybe we should add some events so we don't have to do this)
+ request.prompt = document.getElementById("prompt").value;
+ request.negative_prompt = document.getElementById("negPrompt").value;
+
+ // Use img2img
+
+ // Temporary canvas for init image and mask generation
+ const bbCanvas = document.createElement("canvas");
+ bbCanvas.width = bb.w;
+ bbCanvas.height = bb.h;
+ const bbCtx = bbCanvas.getContext("2d");
+
+ bbCtx.fillStyle = "#000F";
+
+ // Get init image
+ bbCtx.fillRect(0, 0, bb.w, bb.h);
+ bbCtx.drawImage(visibleCanvas, 0, 0);
+ request.init_images = [bbCanvas.toDataURL()];
+
+ // Get mask image
+ bbCtx.fillStyle = state.invertMask ? "#FFFF" : "#000F";
+ bbCtx.fillRect(0, 0, bb.w, bb.h);
+ bbCtx.globalCompositeOperation = "destination-out";
+ bbCtx.drawImage(maskPaintCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
+
+ bbCtx.globalCompositeOperation = "destination-atop";
+ bbCtx.fillStyle = state.invertMask ? "#000F" : "#FFFF";
+ bbCtx.fillRect(0, 0, bb.w, bb.h);
+
+ // Border Mask
+ if (state.keepBorderSize > 0) {
+ const keepBorderCanvas = document.createElement("canvas");
+ keepBorderCanvas.width = request.width;
+ keepBorderCanvas.height = request.height;
+ const keepBorderCtx = keepBorderCanvas.getContext("2d");
+ keepBorderCtx.fillStyle = "#000F";
+
+ if (state.gradient) {
+ const lg = keepBorderCtx.createLinearGradient(
+ 0,
+ 0,
+ state.keepBorderSize,
+ 0
+ );
+ lg.addColorStop(0, "#000F");
+ lg.addColorStop(1, "#0000");
+ keepBorderCtx.fillStyle = lg;
+ }
+ keepBorderCtx.fillRect(0, 0, state.keepBorderSize, request.height);
+ if (state.gradient) {
+ const tg = keepBorderCtx.createLinearGradient(
+ 0,
+ 0,
+ 0,
+ state.keepBorderSize
+ );
+ tg.addColorStop(0, "#000F");
+ tg.addColorStop(1, "#0000");
+ keepBorderCtx.fillStyle = tg;
+ }
+ keepBorderCtx.fillRect(0, 0, request.width, state.keepBorderSize);
+ if (state.gradient) {
+ const rg = keepBorderCtx.createLinearGradient(
+ request.width,
+ 0,
+ request.width - state.keepBorderSize,
+ 0
+ );
+ rg.addColorStop(0, "#000F");
+ rg.addColorStop(1, "#0000");
+ keepBorderCtx.fillStyle = rg;
+ }
+ keepBorderCtx.fillRect(
+ request.width - state.keepBorderSize,
+ 0,
+ state.keepBorderSize,
+ request.height
+ );
+ if (state.gradient) {
+ const bg = keepBorderCtx.createLinearGradient(
+ 0,
+ request.height,
+ 0,
+ request.height - state.keepBorderSize
+ );
+ bg.addColorStop(0, "#000F");
+ bg.addColorStop(1, "#0000");
+ keepBorderCtx.fillStyle = bg;
+ }
+ keepBorderCtx.fillRect(
+ 0,
+ request.height - state.keepBorderSize,
+ request.width,
+ state.keepBorderSize
+ );
+
+ bbCtx.globalCompositeOperation = "source-over";
+ bbCtx.drawImage(
+ keepBorderCanvas,
+ 0,
+ 0,
+ request.width,
+ request.height,
+ 0,
+ 0,
+ bb.w,
+ bb.h
+ );
+ }
+
+ const reqCanvas = document.createElement("canvas");
+ reqCanvas.width = request.width;
+ reqCanvas.height = request.height;
+ const reqCtx = reqCanvas.getContext("2d");
+
+ reqCtx.drawImage(
+ bbCanvas,
+ 0,
+ 0,
+ bb.w,
+ bb.h,
+ 0,
+ 0,
+ request.width,
+ request.height
+ );
+
+ request.mask = reqCanvas.toDataURL();
+ request.inpaint_full_res = state.fullResolution;
+
+ // add dynamic prompts stuff if it's enabled
+ if (extensions.dynamicPromptsEnabled) {
+ addDynamicPromptsToAlwaysOnScripts(state);
+ }
+ if (extensions.controlNetActive) {
+ if (extensions.controlNetReferenceActive) {
+ addControlNetToAlwaysOnScripts(
+ state,
+ request.init_images[0],
+ request.mask
+ );
+ } else {
+ addControlNetToAlwaysOnScripts(state, null, null); // //WTF???
+ }
+ }
+ if (extensions.alwaysOnScripts) {
+ // check again just to be sure because i'm an idiot?
+ // addControlNetToAlwaysOnScripts(state);
+ // addDynamicPromptsToAlwaysOnScripts(state);
+ request.alwayson_scripts = state.alwayson_scripts;
+ }
+
+ // Dream
+ _generate("img2img", request, bb, {
+ keepUnmask: state.keepUnmasked ? bbCanvas : null,
+ keepUnmaskBlur: state.keepUnmaskedBlur,
+ });
+};
+
+/**
+ * Dream and img2img tools
+ */
+
+/**
+ * Generic wheel handler
+ */
+let _dream_wheel_accum = 0;
+
+const _dream_onwheel = (evn, state) => {
+ if (evn.mode !== WheelEvent.DOM_DELTA_PIXEL) {
+ // We don't really handle non-pixel scrolling
+ return;
+ }
+
+ let delta = evn.delta;
+ if (evn.evn.shiftKey) delta *= 0.01;
+
+ // A simple but (I hope) effective fix for mouse wheel behavior
+ _dream_wheel_accum += delta;
+
+ if (
+ !evn.evn.shiftKey &&
+ Math.abs(_dream_wheel_accum) > config.wheelTickSize
+ ) {
+ // Snap to next or previous position
+ const v =
+ state.cursorSize -
+ 128 * (_dream_wheel_accum / Math.abs(_dream_wheel_accum));
+
+ state.cursorSize = state.setCursorSize(v + snap(v, 0, 128));
+ state.mousemovecb(evn);
+
+ _dream_wheel_accum = 0; // Zero accumulation
+ } else if (evn.evn.shiftKey && Math.abs(_dream_wheel_accum) >= 1) {
+ const v = state.cursorSize - _dream_wheel_accum;
+ state.cursorSize = state.setCursorSize(v);
+ state.mousemovecb(evn);
+
+ _dream_wheel_accum = 0; // Zero accumulation
+ }
+};
+
+/**
+ * Registers Tools
+ */
+const dreamTool = () =>
+ toolbar.registerTool(
+ "./res/icons/image-plus.svg",
+ "Dream",
+ (state, opt) => {
+ // Draw new cursor immediately
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+ state.lastMouseMove = {
+ ...mouse.coords.world.pos,
+ };
+ state.redraw();
+
+ // Start Listeners
+ mouse.listen.world.onmousemove.on(state.mousemovecb);
+ mouse.listen.world.onwheel.on(state.wheelcb);
+
+ mouse.listen.world.btn.left.onclick.on(state.dreamcb);
+ mouse.listen.world.btn.right.onclick.on(state.erasecb);
+
+ // Select Region listeners
+ mouse.listen.world.btn.left.ondragstart.on(state.dragstartcb);
+ mouse.listen.world.btn.left.ondrag.on(state.dragcb);
+ mouse.listen.world.btn.left.ondragend.on(state.dragendcb);
+
+ mouse.listen.world.onmousemove.on(state.smousemovecb, 2, true);
+ mouse.listen.world.onwheel.on(state.swheelcb, 2, true);
+ mouse.listen.world.btn.left.onclick.on(state.sdreamcb, 2, true);
+ mouse.listen.world.btn.right.onclick.on(state.serasecb, 2, true);
+ mouse.listen.world.btn.middle.onclick.on(state.smiddlecb, 2, true);
+
+ // Clear Selection
+ state.selection.deselect();
+
+ // Display Mask
+ setMask(state.invertMask ? "hold" : "clear");
+
+ // update cursor size if matching is enabled
+ if (stableDiffusionData.sync_cursor_size) {
+ state.setCursorSize(stableDiffusionData.width);
+ }
+ },
+ (state, opt) => {
+ // Clear Listeners
+ mouse.listen.world.onmousemove.clear(state.mousemovecb);
+ mouse.listen.world.onwheel.clear(state.wheelcb);
+
+ mouse.listen.world.btn.left.onclick.clear(state.dreamcb);
+ mouse.listen.world.btn.right.onclick.clear(state.erasecb);
+
+ // Clear Select Region listeners
+ mouse.listen.world.btn.left.ondragstart.clear(state.dragstartcb);
+ mouse.listen.world.btn.left.ondrag.clear(state.dragcb);
+ mouse.listen.world.btn.left.ondragend.clear(state.dragendcb);
+
+ mouse.listen.world.onmousemove.clear(state.smousemovecb);
+ mouse.listen.world.onwheel.clear(state.swheelcb);
+ mouse.listen.world.btn.left.onclick.clear(state.sdreamcb);
+ mouse.listen.world.btn.right.onclick.clear(state.serasecb);
+ mouse.listen.world.btn.middle.onclick.clear(state.smiddlecb);
+
+ // Clear Selection
+ state.selection.deselect();
+
+ // Hide Mask
+ setMask("none");
+
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+ },
+ {
+ init: (state) => {
+ state.config = {
+ cursorSizeScrollSpeed: 1,
+ };
+
+ state.cursorSize = 512;
+ state.snapToGrid = true;
+ state.invertMask = false;
+ state.keepUnmasked = true;
+ state.keepUnmaskedBlur = 8;
+ state.overMaskPx = 20;
+ state.preserveMasks = false;
+ state.eagerGenerateCount = 0;
+ state.carve_blur = 0;
+ state.carve_threshold = 10;
+
+ state.carve_blur = 0;
+ state.carve_threshold = 10;
+
+ state.erasePrevCursor = () =>
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+ state.erasePrevReticle = () =>
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+
+ state.lastMouseMove = {
+ ...mouse.coords.world.pos,
+ };
+
+ // state.alwayson_scripts = {};
+ // state.alwayson_scripts.controlnet = {};
+ // state.alwayson_scripts.controlnet.args = [
+ // {
+ // module: "none",
+ // model: "None", //docs have this casing, is that necessary?
+ // },
+ // ];
+
+ /**
+ * Selection handlers
+ */
+ const selection = _tool._draggable_selection(state);
+ state.dragstartcb = (evn) => selection.dragstartcb(evn);
+ state.dragcb = (evn) => selection.dragcb(evn);
+ state.dragendcb = (evn) => selection.dragendcb(evn);
+ state.smousemovecb = (evn, estate) => {
+ selection.smousemovecb(evn);
+ if (selection.inside) {
+ imageCollection.inputElement.style.cursor = "pointer";
+
+ estate.dream_processed = true;
+ } else {
+ imageCollection.inputElement.style.cursor = "auto";
+ }
+ };
+ state.swheelcb = (evn, estate) => {
+ if (selection.inside) {
+ state.wheelcb(evn, {});
+ estate.dream_processed = true;
+ }
+ };
+
+ state.sdreamcb = (evn, estate) => {
+ if (selection.exists && !selection.inside) {
+ selection.deselect();
+ state.redraw();
+ estate.selection_processed = true;
+ }
+ if (selection.inside) {
+ state.dreamcb(evn, {});
+ estate.dream_processed = true;
+ }
+ };
+
+ state.serasecb = (evn, estate) => {
+ if (selection.inside) {
+ selection.deselect();
+ state.redraw();
+ estate.dream_processed = true;
+ }
+ };
+ state.smiddlecb = (evn, estate) => {
+ if (selection.inside) {
+ estate.dream_processed = true;
+ }
+ };
+
+ state.selection = selection;
+
+ /**
+ * Dream Handlers
+ */
+ state.mousemovecb = (evn) => {
+ state.lastMouseMove = evn;
+
+ state.erasePrevCursor();
+ state.erasePrevReticle();
+
+ let x = evn.x;
+ let y = evn.y;
+ if (state.snapToGrid) {
+ x += snap(evn.x, 0, 64);
+ y += snap(evn.y, 0, 64);
+ }
+
+ state.erasePrevCursor = _tool._cursor_draw(x, y);
+
+ if (state.selection.exists) {
+ const bb = state.selection.bb;
+
+ const style =
+ state.cursorSize > stableDiffusionData.width
+ ? "#FBB5"
+ : state.cursorSize < stableDiffusionData.width
+ ? "#BFB5"
+ : "#FFF5";
+
+ state.erasePrevReticle = _tool._reticle_draw(
+ bb,
+ "Dream",
+ {
+ w: Math.round(
+ bb.w * (stableDiffusionData.width / state.cursorSize)
+ ),
+ h: Math.round(
+ bb.h * (stableDiffusionData.height / state.cursorSize)
+ ),
+ },
+ {
+ toolTextStyle:
+ global.connection === "online" ? "#FFF5" : "#F555",
+ reticleStyle: state.selection.inside ? "#F55" : "#FFF",
+ sizeTextStyle: style,
+ }
+ );
+ return;
+ }
+
+ const style =
+ state.cursorSize > stableDiffusionData.width
+ ? "#FBB5"
+ : state.cursorSize < stableDiffusionData.width
+ ? "#BFB5"
+ : "#FFF5";
+ state.erasePrevReticle = _tool._reticle_draw(
+ getBoundingBox(
+ evn.x,
+ evn.y,
+ state.cursorSize,
+ state.cursorSize,
+ state.snapToGrid && basePixelCount
+ ),
+ "Dream",
+ {
+ w: stableDiffusionData.width,
+ h: stableDiffusionData.height,
+ },
+ {
+ toolTextStyle: global.connection === "online" ? "#FFF5" : "#F555",
+ sizeTextStyle: style,
+ }
+ );
+ };
+
+ state.redraw = () => {
+ state.mousemovecb(state.lastMouseMove);
+ };
+
+ state.wheelcb = (evn, estate) => {
+ if (estate.dream_processed) return;
+ _dream_onwheel(evn, state);
+ };
+ state.dreamcb = (evn, estate) => {
+ if (estate.dream_processed || estate.selection_processed) return;
+ const bb =
+ state.selection.bb ||
+ getBoundingBox(
+ evn.x,
+ evn.y,
+ state.cursorSize,
+ state.cursorSize,
+ state.snapToGrid && basePixelCount
+ );
+ const resolution = state.selection.bb || {
+ w: stableDiffusionData.width,
+ h: stableDiffusionData.height,
+ };
+
+ if (global.connection === "online") {
+ dream_generate_callback(bb, resolution, state);
+ } else {
+ const stop = march(bb, {
+ title: "offline",
+ titleStyle: "#F555",
+ style: "#F55",
+ });
+ setTimeout(stop, 2000);
+ }
+ state.selection.deselect();
+ state.redraw();
+ };
+ state.erasecb = (evn, estate) => {
+ if (state.selection.exists) {
+ state.selection.deselect();
+ state.redraw();
+ return;
+ }
+ if (estate.dream_processed) return;
+ const bb = getBoundingBox(
+ evn.x,
+ evn.y,
+ state.cursorSize,
+ state.cursorSize,
+ state.snapToGrid && basePixelCount
+ );
+ dream_erase_callback(bb, state);
+ };
+ },
+ populateContextMenu: (menu, state, tool) => {
+ if (!state.ctxmenu) {
+ state.ctxmenu = {};
+
+ // Cursor Size Slider
+ const cursorSizeSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/dream-cursorsize",
+ "cursorSize",
+ "Cursor Size",
+ {
+ min: 128,
+ max: 2048,
+ step: 128,
+ textStep: 2,
+ cb: () => {
+ if (
+ global.syncCursorSize &&
+ resSlider.value !== state.cursorSize
+ ) {
+ resSlider.value = state.cursorSize;
+ }
+
+ if (tool.enabled) state.redraw();
+ },
+ }
+ );
+
+ resSlider.onchange.on(({value}) => {
+ if (global.syncCursorSize && value !== state.cursorSize) {
+ cursorSizeSlider.rawSlider.value = value;
+ }
+ });
+
+ state.setCursorSize = cursorSizeSlider.setValue;
+ state.ctxmenu.cursorSizeSlider = cursorSizeSlider.slider;
+
+ // Snap to Grid Checkbox
+ state.ctxmenu.snapToGridLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/dream-snaptogrid",
+ "snapToGrid",
+ "Snap To Grid",
+ "icon-grid"
+ ).checkbox;
+
+ // Invert Mask Checkbox
+ state.ctxmenu.invertMaskLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/dream-invertmask",
+ "invertMask",
+ "Invert Mask",
+ ["icon-venetian-mask", "invert-mask-checkbox"],
+ () => {
+ setMask(state.invertMask ? "hold" : "clear");
+ }
+ ).checkbox;
+
+ // Keep Unmasked Content Checkbox
+ state.ctxmenu.keepUnmaskedLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/dream-keepunmasked",
+ "keepUnmasked",
+ "Keep Unmasked",
+ "icon-pin",
+ () => {
+ if (state.keepUnmasked) {
+ state.ctxmenu.keepUnmaskedBlurSlider.classList.remove(
+ "invisible"
+ );
+ state.ctxmenu.keepUnmaskedBlurSliderLinebreak.classList.add(
+ "invisible"
+ );
+ } else {
+ state.ctxmenu.keepUnmaskedBlurSlider.classList.add("invisible");
+ state.ctxmenu.keepUnmaskedBlurSliderLinebreak.classList.remove(
+ "invisible"
+ );
+ }
+ }
+ ).checkbox;
+
+ // Keep Unmasked Content Blur Slider
+ state.ctxmenu.keepUnmaskedBlurSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/dream-keepunmaskedblur",
+ "keepUnmaskedBlur",
+ "Keep Unmasked Blur",
+ {
+ min: 0,
+ max: 64,
+ step: 4,
+ textStep: 1,
+ }
+ ).slider;
+
+ state.ctxmenu.keepUnmaskedBlurSliderLinebreak =
+ document.createElement("br");
+ state.ctxmenu.keepUnmaskedBlurSliderLinebreak.classList.add(
+ "invisible"
+ );
+
+ // outpaint fill type select list
+ state.ctxmenu.outpaintTypeSelect = _toolbar_input.selectlist(
+ state,
+ "openoutpaint/dream-outpainttype",
+ "outpainting_fill",
+ "Outpaint Type",
+ {
+ 0: "fill",
+ 1: "original",
+ 2: "latent noise (suggested)",
+ 3: "latent nothing",
+ },
+ 2, // AVOID ORIGINAL FOR OUTPAINT OR ELSE but we still give you the option because we love you and because it seems to work better for SDXL
+ () => {
+ stableDiffusionData.outpainting_fill = state.outpainting_fill;
+ }
+ ).label;
+
+ // Preserve Brushed Masks Checkbox
+ state.ctxmenu.preserveMasksLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/dream-preservemasks",
+ "preserveMasks",
+ "Preserve Brushed Masks",
+ "icon-paintbrush"
+ ).checkbox;
+
+ // Remove Identical/Background Pixels Checkbox
+ state.ctxmenu.removeBackgroundLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/dream-removebg",
+ "removeBackground",
+ "Remove Identical/BG Pixels",
+ "icon-slice",
+ () => {
+ if (state.removeBackground) {
+ state.ctxmenu.carveBlurSlider.classList.remove("invisible");
+ state.ctxmenu.carveThresholdSlider.classList.remove(
+ "invisible"
+ );
+ } else {
+ state.ctxmenu.carveBlurSlider.classList.add("invisible");
+ state.ctxmenu.carveThresholdSlider.classList.add("invisible");
+ }
+ }
+ ).checkbox;
+
+ // controlnet checkbox
+ state.ctxmenu.controlNetLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/dream-controlnet",
+ "controlNet",
+ "Toggle ControlNet In/Outpainting",
+ "icon-joystick"
+ ).checkbox;
+
+ // Overmasking Slider
+ state.ctxmenu.overMaskPxLabel = _toolbar_input.slider(
+ state,
+ "openoutpaint/dream-overmaskpx",
+ "overMaskPx",
+ "Overmask px",
+ {
+ min: 0,
+ max: 64,
+ step: 4,
+ textStep: 1,
+ }
+ ).slider;
+
+ // Eager generation Slider
+ state.ctxmenu.eagerGenerateCountLabel = _toolbar_input.slider(
+ state,
+ "openoutpaint/dream-eagergeneratecount",
+ "eagerGenerateCount",
+ "Generate-ahead count",
+ {
+ min: 0,
+ max: 100,
+ step: 2,
+ textStep: 1,
+ }
+ ).slider;
+ // bg carve blur
+ state.ctxmenu.carveBlurSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/dream-carveblur",
+ "carve_blur",
+ "BG Remove Blur",
+ {
+ min: 0,
+ max: 30,
+ step: 2,
+ textStep: 1,
+ }
+ ).slider;
+ state.ctxmenu.carveBlurSlider.classList.add("invisible");
+ // bg carve threshold
+ state.ctxmenu.carveThresholdSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/dream-carvethreshold",
+ "carve_threshold",
+ "BG Remove Threshold",
+ {
+ min: 0,
+ max: 255,
+ step: 5,
+ textStep: 1,
+ }
+ ).slider;
+ state.ctxmenu.carveThresholdSlider.classList.add("invisible");
+ }
+
+ menu.appendChild(state.ctxmenu.cursorSizeSlider);
+ const array = document.createElement("div");
+ array.classList.add("checkbox-array");
+ array.appendChild(state.ctxmenu.snapToGridLabel);
+ //menu.appendChild(document.createElement("br"));
+ array.appendChild(state.ctxmenu.invertMaskLabel);
+ array.appendChild(state.ctxmenu.preserveMasksLabel);
+ //menu.appendChild(document.createElement("br"));
+ array.appendChild(state.ctxmenu.keepUnmaskedLabel);
+ array.appendChild(state.ctxmenu.removeBackgroundLabel);
+ //TODO: if (global.controlnetAPI) { //but figure out how to update the UI after doing so
+ // never mind i think i'm using an extension menu instead
+ // array.appendChild(state.ctxmenu.controlNetLabel);
+ //}
+ menu.appendChild(array);
+ menu.appendChild(state.ctxmenu.keepUnmaskedBlurSlider);
+ menu.appendChild(state.ctxmenu.carveBlurSlider);
+ menu.appendChild(state.ctxmenu.carveThresholdSlider);
+ // menu.appendChild(state.ctxmenu.keepUnmaskedBlurSliderLinebreak);
+ // menu.appendChild(state.ctxmenu.preserveMasksLabel);
+ // menu.appendChild(document.createElement("br"));
+ menu.appendChild(state.ctxmenu.outpaintTypeSelect);
+ menu.appendChild(state.ctxmenu.overMaskPxLabel);
+ menu.appendChild(state.ctxmenu.eagerGenerateCountLabel);
+ },
+ shortcut: "D",
+ }
+ );
+
+const img2imgTool = () =>
+ toolbar.registerTool(
+ "./res/icons/image.svg",
+ "Img2Img",
+ (state, opt) => {
+ // Draw new cursor immediately
+ state.lastMouseMove = {
+ ...mouse.coords.world.pos,
+ };
+ state.redraw();
+
+ // Start Listeners
+ mouse.listen.world.onmousemove.on(state.mousemovecb);
+ mouse.listen.world.onwheel.on(state.wheelcb);
+
+ mouse.listen.world.btn.left.onclick.on(state.dreamcb);
+ mouse.listen.world.btn.right.onclick.on(state.erasecb);
+
+ // Select Region listeners
+ mouse.listen.world.btn.left.ondragstart.on(state.dragstartcb);
+ mouse.listen.world.btn.left.ondrag.on(state.dragcb);
+ mouse.listen.world.btn.left.ondragend.on(state.dragendcb);
+
+ mouse.listen.world.onmousemove.on(state.smousemovecb, 2, true);
+ mouse.listen.world.onwheel.on(state.swheelcb, 2, true);
+ mouse.listen.world.btn.left.onclick.on(state.sdreamcb, 2, true);
+ mouse.listen.world.btn.right.onclick.on(state.serasecb, 2, true);
+ mouse.listen.world.btn.middle.onclick.on(state.smiddlecb, 2, true);
+
+ // Clear Selection
+ state.selection.deselect();
+
+ // Display Mask
+ setMask(state.invertMask ? "hold" : "clear");
+
+ // update cursor size if matching is enabled
+ if (stableDiffusionData.sync_cursor_size) {
+ state.setCursorSize(stableDiffusionData.width);
+ }
+ },
+ (state, opt) => {
+ // Clear Listeners
+ mouse.listen.world.onmousemove.clear(state.mousemovecb);
+ mouse.listen.world.onwheel.clear(state.wheelcb);
+
+ mouse.listen.world.btn.left.onclick.clear(state.dreamcb);
+ mouse.listen.world.btn.right.onclick.clear(state.erasecb);
+
+ // Clear Select Region listeners
+ mouse.listen.world.btn.left.ondragstart.clear(state.dragstartcb);
+ mouse.listen.world.btn.left.ondrag.clear(state.dragcb);
+ mouse.listen.world.btn.left.ondragend.clear(state.dragendcb);
+
+ mouse.listen.world.onmousemove.clear(state.smousemovecb);
+ mouse.listen.world.onwheel.clear(state.swheelcb);
+ mouse.listen.world.btn.left.onclick.clear(state.sdreamcb);
+ mouse.listen.world.btn.right.onclick.clear(state.serasecb);
+ mouse.listen.world.btn.middle.onclick.clear(state.smiddlecb);
+
+ // Clear Selection
+ state.selection.deselect();
+
+ // Hide mask
+ setMask("none");
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+ },
+ {
+ init: (state) => {
+ state.config = {
+ cursorSizeScrollSpeed: 1,
+ };
+
+ state.cursorSize = 512;
+ state.snapToGrid = true;
+ state.invertMask = true;
+ state.keepUnmasked = true;
+ state.keepUnmaskedBlur = 8;
+ state.fullResolution = false;
+ state.preserveMasks = false;
+ state.eagerGenerateCount = 0;
+ state.carve_blur = 0;
+ state.carve_threshold = 10;
+
+ state.denoisingStrength = 0.7;
+
+ state.keepBorderSize = 64;
+ state.gradient = true;
+
+ state.carve_blur = 0;
+ state.carve_threshold = 10;
+
+ state.erasePrevCursor = () =>
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+ state.erasePrevReticle = () =>
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+
+ state.lastMouseMove = {
+ ...mouse.coords.world.pos,
+ };
+
+ /**
+ * Selection handlers
+ */
+ const selection = _tool._draggable_selection(state);
+ state.dragstartcb = (evn) => selection.dragstartcb(evn);
+ state.dragcb = (evn) => selection.dragcb(evn);
+ state.dragendcb = (evn) => selection.dragendcb(evn);
+ state.smousemovecb = (evn, estate) => {
+ selection.smousemovecb(evn);
+ if (selection.inside) {
+ imageCollection.inputElement.style.cursor = "pointer";
+
+ estate.dream_processed = true;
+ } else {
+ imageCollection.inputElement.style.cursor = "auto";
+ }
+ };
+ state.swheelcb = (evn, estate) => {
+ if (selection.inside) {
+ state.wheelcb(evn, {});
+ estate.dream_processed = true;
+ }
+ };
+
+ state.sdreamcb = (evn, estate) => {
+ if (selection.exists && !selection.inside) {
+ selection.deselect();
+ state.redraw();
+ estate.selection_processed = true;
+ }
+ if (selection.inside) {
+ state.dreamcb(evn, {});
+ estate.dream_processed = true;
+ }
+ };
+
+ state.serasecb = (evn, estate) => {
+ if (selection.inside) {
+ state.erasecb(evn, {});
+ estate.dream_processed = true;
+ }
+ };
+
+ state.smiddlecb = (evn, estate) => {
+ if (selection.inside) {
+ estate.dream_processed = true;
+ }
+ };
+
+ state.selection = selection;
+
+ /**
+ * Dream handlers
+ */
+ state.mousemovecb = (evn) => {
+ state.lastMouseMove = evn;
+
+ state.erasePrevCursor();
+ state.erasePrevReticle();
+
+ let x = evn.x;
+ let y = evn.y;
+ if (state.snapToGrid) {
+ x += snap(evn.x, 0, 64);
+ y += snap(evn.y, 0, 64);
+ }
+
+ state.erasePrevCursor = _tool._cursor_draw(x, y);
+
+ // Resolution
+ let bb = null;
+ let request = null;
+
+ if (state.selection.exists) {
+ bb = state.selection.bb;
+
+ request = {width: bb.w, height: bb.h};
+
+ const style =
+ state.cursorSize > stableDiffusionData.width
+ ? "#FBB5"
+ : state.cursorSize < stableDiffusionData.width
+ ? "#BFB5"
+ : "#FFF5";
+ state.erasePrevReticle = _tool._reticle_draw(
+ bb,
+ "Img2Img",
+ {
+ w: Math.round(
+ bb.w * (stableDiffusionData.width / state.cursorSize)
+ ),
+ h: Math.round(
+ bb.h * (stableDiffusionData.height / state.cursorSize)
+ ),
+ },
+ {
+ toolTextStyle:
+ global.connection === "online" ? "#FFF5" : "#F555",
+ reticleStyle: state.selection.inside ? "#F55" : "#FFF",
+ sizeTextStyle: style,
+ }
+ );
+ } else {
+ bb = getBoundingBox(
+ evn.x,
+ evn.y,
+ state.cursorSize,
+ state.cursorSize,
+ state.snapToGrid && basePixelCount
+ );
+
+ request = {
+ width: stableDiffusionData.width,
+ height: stableDiffusionData.height,
+ };
+
+ const style =
+ state.cursorSize > stableDiffusionData.width
+ ? "#FBB5"
+ : state.cursorSize < stableDiffusionData.width
+ ? "#BFB5"
+ : "#FFF5";
+ state.erasePrevReticle = _tool._reticle_draw(
+ bb,
+ "Img2Img",
+ {w: request.width, h: request.height},
+ {
+ toolTextStyle:
+ global.connection === "online" ? "#FFF5" : "#F555",
+ sizeTextStyle: style,
+ }
+ );
+ }
+
+ if (
+ state.selection.exists &&
+ (state.selection.selected.now.x ===
+ state.selection.selected.start.x ||
+ state.selection.selected.now.y ===
+ state.selection.selected.start.y)
+ ) {
+ return;
+ }
+
+ const bbvp = BoundingBox.fromStartEnd(
+ viewport.canvasToView(bb.tl),
+ viewport.canvasToView(bb.br)
+ );
+
+ // For displaying border mask
+ const bbCanvas = document.createElement("canvas");
+ bbCanvas.width = request.width;
+ bbCanvas.height = request.height;
+ const bbCtx = bbCanvas.getContext("2d");
+
+ if (state.keepBorderSize > 0) {
+ bbCtx.fillStyle = "#6A6AFF30";
+ if (state.gradient) {
+ const lg = bbCtx.createLinearGradient(
+ 0,
+ 0,
+ state.keepBorderSize,
+ 0
+ );
+ lg.addColorStop(0, "#6A6AFF30");
+ lg.addColorStop(1, "#0000");
+ bbCtx.fillStyle = lg;
+ }
+ bbCtx.fillRect(0, 0, state.keepBorderSize, request.height);
+ if (state.gradient) {
+ const tg = bbCtx.createLinearGradient(
+ 0,
+ 0,
+ 0,
+ state.keepBorderSize
+ );
+ tg.addColorStop(0, "#6A6AFF30");
+ tg.addColorStop(1, "#6A6AFF00");
+ bbCtx.fillStyle = tg;
+ }
+ bbCtx.fillRect(0, 0, request.width, state.keepBorderSize);
+ if (state.gradient) {
+ const rg = bbCtx.createLinearGradient(
+ request.width,
+ 0,
+ request.width - state.keepBorderSize,
+ 0
+ );
+ rg.addColorStop(0, "#6A6AFF30");
+ rg.addColorStop(1, "#6A6AFF00");
+ bbCtx.fillStyle = rg;
+ }
+ bbCtx.fillRect(
+ request.width - state.keepBorderSize,
+ 0,
+ state.keepBorderSize,
+ request.height
+ );
+ if (state.gradient) {
+ const bg = bbCtx.createLinearGradient(
+ 0,
+ request.height,
+ 0,
+ request.height - state.keepBorderSize
+ );
+ bg.addColorStop(0, "#6A6AFF30");
+ bg.addColorStop(1, "#6A6AFF00");
+ bbCtx.fillStyle = bg;
+ }
+ bbCtx.fillRect(
+ 0,
+ request.height - state.keepBorderSize,
+ request.width,
+ state.keepBorderSize
+ );
+ uiCtx.drawImage(
+ bbCanvas,
+ 0,
+ 0,
+ request.width,
+ request.height,
+ bbvp.x,
+ bbvp.y,
+ bbvp.w,
+ bbvp.h
+ );
+ }
+ };
+
+ state.redraw = () => {
+ state.mousemovecb(state.lastMouseMove);
+ };
+
+ state.wheelcb = (evn, estate) => {
+ if (estate.dream_processed) return;
+ _dream_onwheel(evn, state);
+ };
+ state.dreamcb = (evn, estate) => {
+ if (estate.dream_processed || estate.selection_processed) return;
+ const bb =
+ state.selection.bb ||
+ getBoundingBox(
+ evn.x,
+ evn.y,
+ state.cursorSize,
+ state.cursorSize,
+ state.snapToGrid && basePixelCount
+ );
+ const resolution = state.selection.bb || {
+ w: stableDiffusionData.width,
+ h: stableDiffusionData.height,
+ };
+ if (global.connection === "online") {
+ dream_img2img_callback(bb, resolution, state);
+ } else {
+ const stop = march(bb, {
+ title: "offline",
+ titleStyle: "#F555",
+ style: "#F55",
+ });
+ setTimeout(stop, 2000);
+ }
+ state.selection.deselect();
+ state.redraw();
+ };
+ state.erasecb = (evn, estate) => {
+ if (estate.dream_processed) return;
+ if (state.selection.exists) {
+ state.selection.deselect();
+ state.redraw();
+ return;
+ }
+ const bb = getBoundingBox(
+ evn.x,
+ evn.y,
+ state.cursorSize,
+ state.cursorSize,
+ state.snapToGrid && basePixelCount
+ );
+ dream_erase_callback(bb, state);
+ };
+ },
+ populateContextMenu: (menu, state, tool) => {
+ if (!state.ctxmenu) {
+ state.ctxmenu = {};
+
+ // Cursor Size Slider
+ const cursorSizeSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/img2img-cursorsize",
+ "cursorSize",
+ "Cursor Size",
+ {
+ min: 128,
+ max: 2048,
+ step: 128,
+ textStep: 2,
+ cb: () => {
+ if (global.syncCursorSize) {
+ resSlider.value = state.cursorSize;
+ }
+
+ if (tool.enabled) state.redraw();
+ },
+ }
+ );
+
+ resSlider.onchange.on(({value}) => {
+ if (global.syncCursorSize && value !== state.cursorSize) {
+ cursorSizeSlider.rawSlider.value = value;
+ }
+ });
+
+ state.setCursorSize = cursorSizeSlider.setValue;
+ state.ctxmenu.cursorSizeSlider = cursorSizeSlider.slider;
+
+ // Snap To Grid Checkbox
+ state.ctxmenu.snapToGridLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/img2img-snaptogrid",
+ "snapToGrid",
+ "Snap To Grid",
+ "icon-grid"
+ ).checkbox;
+
+ // Invert Mask Checkbox
+ state.ctxmenu.invertMaskLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/img2img-invertmask",
+ "invertMask",
+ "Invert Mask",
+ ["icon-venetian-mask", "invert-mask-checkbox"],
+ () => {
+ setMask(state.invertMask ? "hold" : "clear");
+ }
+ ).checkbox;
+
+ // Keep Unmasked Content Checkbox
+ state.ctxmenu.keepUnmaskedLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/img2img-keepunmasked",
+ "keepUnmasked",
+ "Keep Unmasked",
+ "icon-pin",
+ () => {
+ if (state.keepUnmasked) {
+ state.ctxmenu.keepUnmaskedBlurSlider.classList.remove(
+ "invisible"
+ );
+ state.ctxmenu.keepUnmaskedBlurSliderLinebreak.classList.add(
+ "invisible"
+ );
+ } else {
+ state.ctxmenu.keepUnmaskedBlurSlider.classList.add("invisible");
+ state.ctxmenu.keepUnmaskedBlurSliderLinebreak.classList.remove(
+ "invisible"
+ );
+ }
+ }
+ ).checkbox;
+
+ // Keep Unmasked Content Blur Slider
+ state.ctxmenu.keepUnmaskedBlurSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/img2img-unmaskedblur",
+ "keepUnmaskedBlur",
+ "Keep Unmasked Blur",
+ {
+ min: 0,
+ max: 64,
+ step: 4,
+ textStep: 1,
+ }
+ ).slider;
+
+ state.ctxmenu.keepUnmaskedBlurSliderLinebreak =
+ document.createElement("br");
+ state.ctxmenu.keepUnmaskedBlurSliderLinebreak.classList.add(
+ "invisible"
+ );
+
+ // Preserve Brushed Masks Checkbox
+ state.ctxmenu.preserveMasksLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/img2img-preservemasks",
+ "preserveMasks",
+ "Preserve Brushed Masks",
+ "icon-paintbrush"
+ ).checkbox;
+
+ // Inpaint Full Resolution Checkbox
+ state.ctxmenu.fullResolutionLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/img2img-fullresolution",
+ "fullResolution",
+ "Inpaint Full Resolution",
+ "icon-expand"
+ ).checkbox;
+
+ // Denoising Strength Slider
+ state.ctxmenu.denoisingStrengthSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/img2img-denoisingstrength",
+ "denoisingStrength",
+ "Denoising Strength",
+ {
+ min: 0,
+ max: 1,
+ step: 0.05,
+ textStep: 0.01,
+ }
+ ).slider;
+
+ // Border Mask Gradient Checkbox
+ state.ctxmenu.borderMaskGradientCheckbox = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/img2img-gradient",
+ "gradient",
+ "Border Mask Gradient",
+ "icon-box-select"
+ ).checkbox;
+
+ // Remove Identical/Background Pixels Checkbox
+ state.ctxmenu.removeBackgroundLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/img2img-removebg",
+ "removeBackground",
+ "Remove Identical/BG Pixels",
+ "icon-slice",
+ () => {
+ if (state.removeBackground) {
+ state.ctxmenu.carveBlurSlider.classList.remove("invisible");
+ state.ctxmenu.carveThresholdSlider.classList.remove(
+ "invisible"
+ );
+ } else {
+ state.ctxmenu.carveBlurSlider.classList.add("invisible");
+ state.ctxmenu.carveThresholdSlider.classList.add("invisible");
+ }
+ }
+ ).checkbox;
+
+ // Border Mask Size Slider
+ state.ctxmenu.borderMaskSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/img2img-keepbordersize",
+ "keepBorderSize",
+ "Keep Border Size",
+ {
+ min: 0,
+ max: 128,
+ step: 8,
+ textStep: 1,
+ }
+ ).slider;
+
+ // inpaint fill type select list
+ state.ctxmenu.inpaintTypeSelect = _toolbar_input.selectlist(
+ state,
+ "openoutpaint/img2img-inpaintingtype",
+ "inpainting_fill",
+ "Inpaint Type",
+ {
+ 0: "fill",
+ 1: "original (recommended)",
+ 2: "latent noise",
+ 3: "latent nothing",
+ },
+ 1, // USE ORIGINAL FOR IMG2IMG OR ELSE but we still give you the option because we love you
+ () => {
+ stableDiffusionData.inpainting_fill = state.inpainting_fill;
+ }
+ ).label;
+
+ // Eager generation Slider
+ state.ctxmenu.eagerGenerateCountLabel = _toolbar_input.slider(
+ state,
+ "openoutpaint/img2img-eagergeneratecount",
+ "eagerGenerateCount",
+ "Generate-ahead count",
+ {
+ min: 0,
+ max: 100,
+ step: 2,
+ textStep: 1,
+ }
+ ).slider;
+
+ // img cfg scale slider for instruct-pix2pix
+ state.ctxmenu.instructPix2PixImgCfgLabel = _toolbar_input.slider(
+ state,
+ "openoutpaint/img2img-ip2pcfg",
+ "image_cfg_scale",
+ "iP2P Image CFG Scale",
+ {
+ min: 0,
+ max: 30,
+ step: 1,
+ textStep: 0.1,
+ }
+ ).slider;
+ state.ctxmenu.instructPix2PixImgCfgLabel.classList.add(
+ "instruct-pix2pix-img-cfg"
+ );
+
+ // bg carve blur
+ state.ctxmenu.carveBlurSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/img2img-carveblur",
+ "carve_blur",
+ "BG Remove Blur",
+ {
+ min: 0,
+ max: 30,
+ step: 2,
+ textStep: 1,
+ }
+ ).slider;
+ state.ctxmenu.carveBlurSlider.classList.add("invisible");
+
+ // bg carve threshold
+ state.ctxmenu.carveThresholdSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/img2img-carvethreshold",
+ "carve_threshold",
+ "BG Remove Threshold",
+ {
+ min: 0,
+ max: 255,
+ step: 5,
+ textStep: 1,
+ }
+ ).slider;
+ state.ctxmenu.carveThresholdSlider.classList.add("invisible");
+ }
+
+ menu.appendChild(state.ctxmenu.cursorSizeSlider);
+ const array = document.createElement("div");
+ array.classList.add("checkbox-array");
+ array.appendChild(state.ctxmenu.snapToGridLabel);
+ array.appendChild(state.ctxmenu.invertMaskLabel);
+ array.appendChild(state.ctxmenu.preserveMasksLabel);
+ array.appendChild(state.ctxmenu.keepUnmaskedLabel);
+ array.appendChild(state.ctxmenu.removeBackgroundLabel);
+ menu.appendChild(array);
+ menu.appendChild(state.ctxmenu.keepUnmaskedBlurSlider);
+ menu.appendChild(state.ctxmenu.carveBlurSlider);
+ menu.appendChild(state.ctxmenu.carveThresholdSlider);
+ // menu.appendChild(state.ctxmenu.keepUnmaskedBlurSliderLinebreak);
+ menu.appendChild(state.ctxmenu.inpaintTypeSelect);
+ menu.appendChild(state.ctxmenu.denoisingStrengthSlider);
+ menu.appendChild(state.ctxmenu.instructPix2PixImgCfgLabel);
+ const btnArray2 = document.createElement("div");
+ btnArray2.classList.add("checkbox-array");
+ btnArray2.appendChild(state.ctxmenu.fullResolutionLabel);
+ btnArray2.appendChild(state.ctxmenu.borderMaskGradientCheckbox);
+ menu.appendChild(btnArray2);
+ menu.appendChild(state.ctxmenu.borderMaskSlider);
+ menu.appendChild(state.ctxmenu.eagerGenerateCountLabel);
+ },
+ shortcut: "I",
+ }
+ );
+
+const sendSeed = (seed) => {
+ stableDiffusionData.seed = document.getElementById("seed").value = seed;
+};
+
+function buildAlwaysOnScripts(state) {
+ if (extensions.alwaysOnScripts) {
+ state.alwayson_scripts = {};
+ }
+}
+
+function addDynamicPromptsToAlwaysOnScripts(state) {
+ if (extensions.dynamicPromptsEnabled) {
+ state.alwayson_scripts[extensions.dynamicPromptsAlwaysonScriptName] = {};
+ state.alwayson_scripts[extensions.dynamicPromptsAlwaysonScriptName].args = [
+ extensions.dynamicPromptsActive,
+ ];
+ }
+}
+
+function addControlNetToAlwaysOnScripts(state, initCanvas, maskCanvas) {
+ var initimg =
+ toolbar._current_tool.name == "Dream" ? initCanvas.toDataURL() : initCanvas;
+ var maskimg =
+ toolbar._current_tool.name == "Dream" ? maskCanvas.toDataURL() : maskCanvas;
+ if (extensions.controlNetEnabled && extensions.controlNetActive) {
+ state.alwayson_scripts.controlnet = {};
+ if (initCanvas == null && maskCanvas == null) {
+ //img2img?
+ state.alwayson_scripts.controlnet.args = [
+ {
+ module: extensions.selectedControlNetModule,
+ model: extensions.selectedControlNetModel,
+ control_mode: document.getElementById("controlNetMode-select").value,
+ processor_res: 64,
+ resize_mode: document.getElementById("controlNetResize-select").value,
+ // resize mode?
+ // weights / steps?
+ },
+ ];
+ } else {
+ state.alwayson_scripts.controlnet.args = [
+ {
+ module: extensions.selectedControlNetModule,
+ model: extensions.selectedControlNetModel,
+ control_mode: document.getElementById("controlNetMode-select").value,
+ input_image: initimg, //initCanvas.toDataURL(),
+ mask: maskimg, //maskCanvas.toDataURL(),
+ processor_res: 64,
+ resize_mode: document.getElementById("controlNetResize-select").value,
+ // resize mode?
+ // weights / steps?
+ },
+ ];
+ }
+ if (extensions.controlNetReferenceActive) {
+ state.alwayson_scripts.controlnet.args.unshift({
+ enabled: true,
+ module: extensions.selectedCNReferenceModule,
+ model: "None",
+ control_mode: document.getElementById("controlNetReferenceMode-select")
+ .value,
+ image: initimg, //initCanvas.toDataURL(),
+ processor_res: 64,
+ threshold_a: extensions.controlNetReferenceFidelity,
+ threshold_b: 64,
+ resize_mode: document.getElementById("controlNetResize-select").value,
+ });
+ }
+ }
+
+ var deleteme = 2463;
+ var ok = deleteme / 36;
+
+ // request.alwayson_scripts = state.alwayson_scripts;
+ // }
+}
+
+// function getImageAndMask(visibleCanvas, bb, request, state) {
+// // get input image
+// // Temporary canvas for init image and mask generation
+// const bbCanvas = document.createElement("canvas");
+// bbCanvas.width = bb.w;
+// bbCanvas.height = bb.h;
+// const bbCtx = bbCanvas.getContext("2d");
+
+// const maskCanvas = document.createElement("canvas");
+// maskCanvas.width = request.width;
+// maskCanvas.height = request.height;
+// const maskCtx = maskCanvas.getContext("2d");
+
+// const initCanvas = document.createElement("canvas");
+// initCanvas.width = request.width;
+// initCanvas.height = request.height;
+// const initCtx = initCanvas.getContext("2d");
+
+// bbCtx.fillStyle = "#000F";
+
+// // Get init image
+// initCtx.fillRect(0, 0, request.width, request.height);
+// initCtx.drawImage(
+// visibleCanvas,
+// 0,
+// 0,
+// bb.w,
+// bb.h,
+// 0,
+// 0,
+// request.width,
+// request.height
+// );
+// // request.init_images = [initCanvas.toDataURL()];
+
+// // Get mask image
+// bbCtx.fillStyle = "#000F";
+// bbCtx.fillRect(0, 0, bb.w, bb.h);
+// if (state.invertMask) {
+// // overmasking by definition is entirely pointless with an inverted mask outpaint
+// // since it should explicitly avoid brushed masks too, we just won't even bother
+// bbCtx.globalCompositeOperation = "destination-in";
+// bbCtx.drawImage(maskPaintCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
+
+// bbCtx.globalCompositeOperation = "destination-in";
+// bbCtx.drawImage(visibleCanvas, 0, 0);
+// } else {
+// bbCtx.globalCompositeOperation = "destination-in";
+// bbCtx.drawImage(visibleCanvas, 0, 0);
+// // here's where to overmask to avoid including the brushed mask
+// // 99% of my issues were from failing to set source-over for the overmask blotches
+// if (state.overMaskPx > 0) {
+// // transparent to white first
+// bbCtx.globalCompositeOperation = "destination-atop";
+// bbCtx.fillStyle = "#FFFF";
+// bbCtx.fillRect(0, 0, bb.w, bb.h);
+// applyOvermask(bbCanvas, bbCtx, state.overMaskPx);
+// }
+
+// bbCtx.globalCompositeOperation = "destination-out"; // ???
+// bbCtx.drawImage(maskPaintCanvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
+// }
+
+// bbCtx.globalCompositeOperation = "destination-atop";
+// bbCtx.fillStyle = "#FFFF";
+// bbCtx.fillRect(0, 0, bb.w, bb.h);
+
+// maskCtx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
+// maskCtx.drawImage(
+// bbCanvas,
+// 0,
+// 0,
+// bb.w,
+// bb.h,
+// 0,
+// 0,
+// request.width,
+// request.height
+// );
+// }
diff --git a/openOutpaint-webUI-extension/app/js/ui/tool/generic.js b/openOutpaint-webUI-extension/app/js/ui/tool/generic.js
new file mode 100644
index 0000000000000000000000000000000000000000..04073b8ec7b6efcdf8f27930ec3b96757f3d4705
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/ui/tool/generic.js
@@ -0,0 +1,659 @@
+/**
+ * File to add generic rendering functions and shared utilities
+ */
+
+const _tool = {
+ /**
+ * Draws a reticle used for image generation
+ *
+ * @param {BoundingBox} bb The bounding box of the reticle (world space)
+ * @param {string} tool Name of the tool to diplay
+ * @param {{w: number, h: number}} resolution Resolution of generation to display
+ * @param {object} style Styles to use for rendering the reticle
+ * @param {string} [style.sizeTextStyle = "#FFF5"] Style of the text for diplaying the bounding box size.
+ * @param {string} [style.genSizeTextStyle = "#FFF5"] Style of the text for diplaying generation size
+ * @param {string} [style.toolTextStyle = "#FFF5"] Style of the text for the tool name
+ * @param {number} [style.reticleWidth = 1] Width of the line of the reticle
+ * @param {string} [style.reticleStyle] Style of the line of the reticle
+ *
+ * @returns A function that erases this reticle drawing
+ */
+ _reticle_draw(bb, tool, resolution, style = {}) {
+ defaultOpt(style, {
+ sizeTextStyle: "#FFF5",
+ genSizeTextStyle: "#FFF5",
+ toolTextStyle: "#FFF5",
+ reticleWidth: 1,
+ reticleStyle: global.hasActiveInput ? "#BBF" : "#FFF",
+ });
+
+ const bbvp = bb.transform(viewport.c2v);
+
+ uiCtx.save();
+
+ // Draw targeting square reticle thingy cursor
+ uiCtx.lineWidth = style.reticleWidth;
+ uiCtx.strokeStyle = style.reticleStyle;
+ uiCtx.strokeRect(bbvp.x, bbvp.y, bbvp.w, bbvp.h); // Origin is middle of the frame
+
+ uiCtx.font = `bold 20px Open Sans`;
+
+ // Draw Tool Name
+ if (bb.h > 40) {
+ const xshrink = Math.min(
+ 1,
+ (bbvp.w - 20) / uiCtx.measureText(tool).width
+ );
+
+ uiCtx.font = `bold ${20 * xshrink}px Open Sans`;
+
+ uiCtx.textAlign = "left";
+ uiCtx.fillStyle = style.toolTextStyle;
+ uiCtx.fillText(tool, bbvp.x + 10, bbvp.y + 10 + 20 * xshrink, bb.w);
+ }
+
+ // Draw width and height
+ {
+ // Render Cursor Width
+ uiCtx.textAlign = "center";
+ uiCtx.fillStyle = style.sizeTextStyle;
+ uiCtx.translate(bbvp.x + bbvp.w / 2, bbvp.y + bbvp.h / 2);
+ const xshrink = Math.min(
+ 1,
+ (bbvp.w - 30) / uiCtx.measureText(`${bb.w}px`).width
+ );
+ const yshrink = Math.min(
+ 1,
+ (bbvp.h - 30) / uiCtx.measureText(`${bb.h}px`).width
+ );
+ uiCtx.font = `bold ${20 * xshrink}px Open Sans`;
+ uiCtx.fillText(`${bb.w}px`, 0, bbvp.h / 2 - 10 * xshrink, bb.w);
+
+ // Render Generation Width
+ uiCtx.fillStyle = style.genSizeTextStyle;
+ uiCtx.font = `bold ${10 * xshrink}px Open Sans`;
+ if (bb.w !== resolution.w)
+ uiCtx.fillText(`${resolution.w}px`, 0, bbvp.h / 2 - 30 * xshrink, bb.h);
+
+ // Render Cursor Height
+ uiCtx.rotate(-Math.PI / 2);
+ uiCtx.fillStyle = style.sizeTextStyle;
+ uiCtx.font = `bold ${20 * yshrink}px Open Sans`;
+ uiCtx.fillText(`${bb.h}px`, 0, bbvp.w / 2 - 10 * yshrink, bb.h);
+
+ // Render Generation Height
+ uiCtx.fillStyle = style.genSizeTextStyle;
+ uiCtx.font = `bold ${10 * yshrink}px Open Sans`;
+ if (bb.h !== resolution.h)
+ uiCtx.fillText(`${resolution.h}px`, 0, bbvp.w / 2 - 30 * xshrink, bb.h);
+
+ uiCtx.restore();
+ }
+
+ return () => {
+ uiCtx.save();
+
+ uiCtx.clearRect(bbvp.x - 64, bbvp.y - 64, bbvp.w + 128, bbvp.h + 128);
+
+ uiCtx.restore();
+ };
+ },
+
+ /**
+ * Draws a generic crosshair cursor at the specified location
+ *
+ * @param {number} x X world coordinate of the cursor
+ * @param {number} y Y world coordinate of the cursor
+ * @param {object} style Style of the lines of the cursor
+ * @param {string} [style.width = 3] Line width of the lines of the cursor
+ * @param {string} [style.style] Stroke style of the lines of the cursor
+ *
+ * @returns A function that erases this cursor drawing
+ */
+ _cursor_draw(x, y, style = {}) {
+ defaultOpt(style, {
+ width: 3,
+ style: global.hasActiveInput ? "#BBF5" : "#FFF5",
+ });
+ const vpc = viewport.canvasToView(x, y);
+
+ // Draw current cursor location
+ uiCtx.lineWidth = style.width;
+ uiCtx.strokeStyle = style.style;
+
+ uiCtx.beginPath();
+ uiCtx.moveTo(vpc.x, vpc.y + 10);
+ uiCtx.lineTo(vpc.x, vpc.y - 10);
+ uiCtx.moveTo(vpc.x + 10, vpc.y);
+ uiCtx.lineTo(vpc.x - 10, vpc.y);
+ uiCtx.stroke();
+ return () => {
+ uiCtx.clearRect(vpc.x - 15, vpc.y - 15, vpc.x + 30, vpc.y + 30);
+ };
+ },
+
+ /**
+ * Creates generic handlers for dealing with draggable selection areas
+ *
+ * @param {object} state State of the tool
+ * @param {boolean} state.snapToGrid Whether the cursor should snap to the grid
+ * @param {() => void} [state.redraw] Function to redraw the cursor
+ * @returns
+ */
+ _draggable_selection(state) {
+ const selection = {
+ _inside: false,
+ _dirty_bb: true,
+ _cached_bb: null,
+ _selected: null,
+
+ /**
+ * If the cursor is cursor is currently inside the selection
+ */
+ get inside() {
+ return this._inside;
+ },
+
+ /**
+ * Get intermediate selection object
+ */
+ get selected() {
+ return this._selected;
+ },
+
+ /**
+ * If the selection exists
+ */
+ get exists() {
+ return !!this._selected;
+ },
+
+ /**
+ * Gets the selection bounding box
+ *
+ * @returns {BoundingBox}
+ */
+ get bb() {
+ if (this._dirty_bb && this._selected) {
+ this._cached_bb = BoundingBox.fromStartEnd(
+ this._selected.start,
+ this._selected.now
+ );
+ this._dirty_bb = false;
+ }
+ return this._selected && this._cached_bb;
+ },
+
+ /**
+ * When the cursor enters the selection
+ */
+ onenter: new Observer(),
+
+ /**
+ * When the cursor leaves the selection
+ */
+ onleave: new Observer(),
+
+ // Utility methods
+ deselect() {
+ if (this.inside) {
+ this._inside = false;
+ this.onleave.emit({evn: null});
+ }
+ this._selected = null;
+ },
+
+ // Dragging handlers
+ /**
+ * Drag start event handler
+ *
+ * @param {Point} evn Drag start event
+ */
+ dragstartcb(evn) {
+ const x = state.snapToGrid ? evn.ix + snap(evn.ix, 0, 64) : evn.ix;
+ const y = state.snapToGrid ? evn.iy + snap(evn.iy, 0, 64) : evn.iy;
+ this._selected = {start: {x, y}, now: {x, y}};
+ this._dirty_bb = true;
+ },
+ /**
+ * Drag event handler
+ *
+ * @param {Point} evn Drag event
+ */
+ dragcb(evn) {
+ const x = state.snapToGrid ? evn.x + snap(evn.x, 0, 64) : evn.x;
+ const y = state.snapToGrid ? evn.y + snap(evn.y, 0, 64) : evn.y;
+
+ if (x !== this._selected.now.x || y !== this._selected.now.y) {
+ this._selected.now = {x, y};
+ this._dirty_bb = true;
+ }
+ },
+ /**
+ * Drag end event handler
+ *
+ * @param {Point} evn Drag end event
+ */
+ dragendcb(evn) {
+ const x = state.snapToGrid ? evn.x + snap(evn.x, 0, 64) : evn.x;
+ const y = state.snapToGrid ? evn.y + snap(evn.y, 0, 64) : evn.y;
+
+ this._selected.now = {x, y};
+ this._dirty_bb = true;
+
+ if (
+ this._selected.start.x === this._selected.now.x ||
+ this._selected.start.y === this._selected.now.y
+ ) {
+ this.deselect();
+ }
+ },
+
+ /**
+ * Mouse move event handler
+ *
+ * @param {Point} evn Mouse move event
+ */
+ smousemovecb(evn) {
+ if (!this._selected || !this.bb.contains(evn.x, evn.y)) {
+ if (this.inside) {
+ this._inside = false;
+ this.onleave.emit({evn});
+ }
+ } else {
+ if (!this.inside) {
+ this._inside = true;
+ this.onenter.emit({evn});
+ }
+ }
+ },
+ };
+
+ return selection;
+ },
+
+ /**
+ * Processes cursor position
+ *
+ * @param {Point} wpoint World coordinate of the cursor
+ * @param {boolean} snapToGrid Snap to grid
+ */
+ _process_cursor(wpoint, snapToGrid) {
+ // Get cursor positions
+ let x = wpoint.x;
+ let y = wpoint.y;
+ let sx = x;
+ let sy = y;
+
+ if (snapToGrid) {
+ sx += snap(x, 0, config.gridSize);
+ sy += snap(y, 0, config.gridSize);
+ }
+
+ const vpc = viewport.canvasToView(x, y);
+ const vpsc = viewport.canvasToView(sx, sy);
+
+ return {
+ // World Coordinates
+ x,
+ y,
+ sx,
+ sy,
+
+ // Viewport Coordinates
+ vpx: vpc.x,
+ vpy: vpc.y,
+ vpsx: vpsc.x,
+ vpsy: vpsc.y,
+ };
+ },
+
+ /**
+ * Represents a marquee selection with an image
+ */
+ MarqueeSelection: class {
+ /** @type {HTMLCanvasElement} */
+ canvas;
+
+ _dirty = false;
+ _position = {x: 0, y: 0};
+ /**
+ * @type {Point}
+ */
+ get position() {
+ return this._position;
+ }
+ set position(v) {
+ this._dirty = true;
+ this._position = v;
+ }
+
+ _scale = {x: 1, y: 1};
+ /**
+ * @type {Point}
+ */
+ get scale() {
+ return this._scale;
+ }
+ set scale(v) {
+ if (v.x === 0 || v.y === 0) return;
+ this._dirty = true;
+ this._scale = v;
+ }
+
+ _rotation = 0;
+ get rotation() {
+ return this._rotation;
+ }
+ set rotation(v) {
+ this._dirty = true;
+ this._rotation = v;
+ }
+
+ /**
+ * @param {HTMLCanvasElement} canvas Selected image canvas
+ * @param {Point} position Initial position of the selection
+ */
+ constructor(canvas, position = {x: 0, y: 0}) {
+ this.canvas = canvas;
+ this.position = position;
+ }
+
+ /** @type {DOMMatrix} */
+ _rtmatrix = null;
+ get rtmatrix() {
+ if (!this._rtmatrix || this._dirty) {
+ const m = new DOMMatrix();
+
+ m.translateSelf(this.position.x, this.position.y);
+ m.rotateSelf((this.rotation * 180) / Math.PI);
+
+ this._rtmatrix = m;
+ }
+
+ return this._rtmatrix;
+ }
+
+ /** @type {DOMMatrix} */
+ _matrix = null;
+ get matrix() {
+ if (!this._matrix || this._dirty) {
+ this._matrix = this.rtmatrix.scaleSelf(this.scale.x, this.scale.y);
+ }
+ return this._matrix;
+ }
+
+ /**
+ * If the main marquee box contains a given point
+ *
+ * @param {number} x X coordinate of the point
+ * @param {number} y Y coordinate of the point
+ */
+ contains(x, y) {
+ const p = this.matrix.invertSelf().transformPoint({x, y});
+
+ return (
+ Math.abs(p.x) < this.canvas.width / 2 &&
+ Math.abs(p.y) < this.canvas.height / 2
+ );
+ }
+
+ hoveringRotateHandle(x, y, scale = 1) {
+ const localc = this.rtmatrix.inverse().transformPoint({x, y});
+ const localrh = {
+ x: 0,
+ y:
+ (-this.scale.y * this.canvas.height) / 2 -
+ config.rotateHandleDistance * scale,
+ };
+
+ const dx = Math.abs(localc.x - localrh.x);
+ const dy = Math.abs(localc.y - localrh.y);
+
+ return (
+ dx * dx + dy * dy <
+ (scale * scale * config.handleDetectSize * config.handleDetectSize) / 4
+ );
+ }
+
+ hoveringHandle(x, y, scale = 1) {
+ const localbb = new BoundingBox({
+ x: (this.scale.x * -this.canvas.width) / 2,
+ y: (this.scale.y * -this.canvas.height) / 2,
+ w: this.canvas.width * this.scale.x,
+ h: this.canvas.height * this.scale.y,
+ });
+
+ const localc = this.rtmatrix.inverse().transformPoint({x, y});
+ const ontl =
+ Math.max(
+ Math.abs(localc.x - localbb.tl.x),
+ Math.abs(localc.y - localbb.tl.y)
+ ) <
+ (config.handleDetectSize / 2) * scale;
+ const ontr =
+ Math.max(
+ Math.abs(localc.x - localbb.tr.x),
+ Math.abs(localc.y - localbb.tr.y)
+ ) <
+ (config.handleDetectSize / 2) * scale;
+ const onbl =
+ Math.max(
+ Math.abs(localc.x - localbb.bl.x),
+ Math.abs(localc.y - localbb.bl.y)
+ ) <
+ (config.handleDetectSize / 2) * scale;
+ const onbr =
+ Math.max(
+ Math.abs(localc.x - localbb.br.x),
+ Math.abs(localc.y - localbb.br.y)
+ ) <
+ (config.handleDetectSize / 2) * scale;
+
+ return {onHandle: ontl || ontr || onbl || onbr, ontl, ontr, onbl, onbr};
+ }
+
+ hoveringBox(x, y) {
+ const localbb = new BoundingBox({
+ x: -this.canvas.width / 2,
+ y: -this.canvas.height / 2,
+ w: this.canvas.width,
+ h: this.canvas.height,
+ });
+
+ const localc = this.matrix.inverse().transformPoint({x, y});
+
+ return (
+ !this.hoveringHandle(x, y).onHandle &&
+ localbb.contains(localc.x, localc.y)
+ );
+ }
+
+ /**
+ * Draws the marquee selector box
+ *
+ * @param {CanvasRenderingContext2D} context A context for rendering the box to
+ * @param {Point} cursor Cursor position
+ * @param {DOMMatrix} transform A transformation matrix to transform the position by
+ */
+ drawBox(context, cursor, transform = new DOMMatrix()) {
+ const drawscale =
+ 1 / Math.sqrt(transform.a * transform.a + transform.b * transform.b);
+ const m = transform.multiply(this.matrix);
+
+ context.save();
+
+ const localbb = new BoundingBox({
+ x: -this.canvas.width / 2,
+ y: -this.canvas.height / 2,
+ w: this.canvas.width,
+ h: this.canvas.height,
+ });
+
+ // Line Style
+ context.strokeStyle = "#FFF";
+ context.lineWidth = 2;
+
+ const tl = m.transformPoint(localbb.tl);
+ const tr = m.transformPoint(localbb.tr);
+ const bl = m.transformPoint(localbb.bl);
+ const br = m.transformPoint(localbb.br);
+
+ const bbc = m.transformPoint({x: 0, y: 0});
+
+ context.beginPath();
+ context.arc(bbc.x, bbc.y, 5, 0, Math.PI * 2);
+ context.stroke();
+
+ context.setLineDash([4, 2]);
+
+ // Draw main rectangle
+ context.beginPath();
+ context.moveTo(tl.x, tl.y);
+ context.lineTo(tr.x, tr.y);
+ context.lineTo(br.x, br.y);
+ context.lineTo(bl.x, bl.y);
+ context.lineTo(tl.x, tl.y);
+ context.stroke();
+
+ // Draw rotation handle
+ context.setLineDash([]);
+
+ const hm = new DOMMatrix().rotateSelf((this.rotation * 180) / Math.PI);
+ const tm = m.transformPoint({x: 0, y: -this.canvas.height / 2});
+ const rho = hm.transformPoint({x: 0, y: -config.rotateHandleDistance});
+ const rh = {x: tm.x + rho.x, y: tm.y + rho.y};
+
+ let handleRadius = config.handleDrawSize / 2;
+ if (this.hoveringRotateHandle(cursor.x, cursor.y, drawscale))
+ handleRadius *= config.handleDrawHoverScale;
+
+ context.beginPath();
+ context.moveTo(tm.x, tm.y);
+ context.lineTo(rh.x, rh.y);
+ context.stroke();
+
+ context.beginPath();
+ context.arc(rh.x, rh.y, handleRadius, 0, 2 * Math.PI);
+ context.stroke();
+
+ // Draw handles
+ const drawHandle = (pt, hover) => {
+ let hsz = config.handleDrawSize / 2;
+ if (hover) hsz *= config.handleDrawHoverScale;
+
+ const htl = hm.transformPoint({x: -hsz, y: -hsz});
+ const htr = hm.transformPoint({x: hsz, y: -hsz});
+ const hbr = hm.transformPoint({x: hsz, y: hsz});
+ const hbl = hm.transformPoint({x: -hsz, y: hsz});
+
+ context.beginPath();
+ context.moveTo(htl.x + pt.x, htl.y + pt.y);
+ context.lineTo(htr.x + pt.x, htr.y + pt.y);
+ context.lineTo(hbr.x + pt.x, hbr.y + pt.y);
+ context.lineTo(hbl.x + pt.x, hbl.y + pt.y);
+ context.lineTo(htl.x + pt.x, htl.y + pt.y);
+ context.stroke();
+ };
+
+ context.strokeStyle = "#FFF";
+ context.lineWidth = 2;
+ context.setLineDash([]);
+
+ const {ontl, ontr, onbl, onbr} = this.hoveringHandle(
+ cursor.x,
+ cursor.y,
+ drawscale
+ );
+
+ drawHandle(tl, ontl);
+ drawHandle(tr, ontr);
+ drawHandle(bl, onbl);
+ drawHandle(br, onbr);
+
+ context.restore();
+
+ return () => {
+ const border = config.handleDrawSize * config.handleDrawHoverScale;
+
+ const minx = Math.min(tl.x, tr.x, bl.x, br.x, rh.x) - border;
+ const maxx = Math.max(tl.x, tr.x, bl.x, br.x, rh.x) + border;
+ const miny = Math.min(tl.y, tr.y, bl.y, br.y, rh.y) - border;
+ const maxy = Math.max(tl.y, tr.y, bl.y, br.y, rh.y) + border;
+
+ context.clearRect(minx, miny, maxx - minx, maxy - miny);
+ };
+ }
+
+ /**
+ * Draws the selected image
+ *
+ * @param {CanvasRenderingContext2D} context A context for rendering the image to
+ * @param {CanvasRenderingContext2D} peekctx A context for rendering the layer peeking to
+ * @param {object} options
+ * @param {DOMMatrix} options.transform A transformation matrix to transform the position by
+ * @param {number} options.opacity Opacity of the peek display
+ */
+ drawImage(context, peekctx, options = {}) {
+ defaultOpt(options, {
+ transform: new DOMMatrix(),
+ opacity: 0.4,
+ });
+
+ context.save();
+ peekctx.save();
+
+ const m = options.transform.multiply(this.matrix);
+
+ // Draw image
+ context.setTransform(m);
+ context.drawImage(
+ this.canvas,
+ -this.canvas.width / 2,
+ -this.canvas.height / 2,
+ this.canvas.width,
+ this.canvas.height
+ );
+
+ // Draw peek
+ peekctx.filter = `opacity(${options.opacity * 100}%)`;
+ peekctx.setTransform(m);
+ peekctx.drawImage(
+ this.canvas,
+ -this.canvas.width / 2,
+ -this.canvas.height / 2,
+ this.canvas.width,
+ this.canvas.height
+ );
+
+ peekctx.restore();
+ context.restore();
+
+ return () => {
+ // Here we only save transform for performance
+ const pt = context.getTransform();
+ const ppt = context.getTransform();
+
+ context.setTransform(m);
+ peekctx.setTransform(m);
+
+ context.clearRect(
+ -this.canvas.width / 2 - 10,
+ -this.canvas.height / 2 - 10,
+ this.canvas.width + 20,
+ this.canvas.height + 20
+ );
+
+ peekctx.clearRect(
+ -this.canvas.width / 2 - 10,
+ -this.canvas.height / 2 - 10,
+ this.canvas.width + 20,
+ this.canvas.height + 20
+ );
+
+ context.setTransform(pt);
+ peekctx.setTransform(ppt);
+ };
+ }
+ },
+};
diff --git a/openOutpaint-webUI-extension/app/js/ui/tool/interrogate.js b/openOutpaint-webUI-extension/app/js/ui/tool/interrogate.js
new file mode 100644
index 0000000000000000000000000000000000000000..2a41ed2d718b2111f09d4cb963ad8456375f8d80
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/ui/tool/interrogate.js
@@ -0,0 +1,191 @@
+const interrogateTool = () =>
+ toolbar.registerTool(
+ "./res/icons/microscope.svg",
+ "Interrogate",
+ (state, opt) => {
+ // Draw new cursor immediately
+ ovLayer.clear();
+ state.redraw();
+
+ // Start Listeners
+ mouse.listen.world.onmousemove.on(state.mousemovecb);
+ mouse.listen.world.onwheel.on(state.wheelcb);
+ mouse.listen.world.btn.left.onclick.on(state.interrogatecb);
+ },
+ (state, opt) => {
+ // Clear Listeners
+ mouse.listen.world.onmousemove.clear(state.mousemovecb);
+ mouse.listen.world.onwheel.clear(state.wheelcb);
+ mouse.listen.world.btn.left.onclick.clear(state.interrogatecb);
+
+ // Hide Mask
+ setMask("none");
+
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+ },
+ {
+ init: (state) => {
+ state.config = {
+ cursorSizeScrollSpeed: 1,
+ };
+
+ state.cursorSize = 512;
+
+ state.snapToGrid = true;
+ state.invertMask = false;
+ state.block_res_change = true;
+
+ state.erasePrevReticle = () => ovLayer.clear();
+
+ state.mousemovecb = (evn) => {
+ state.erasePrevReticle();
+ state.erasePrevReticle = _tool._reticle_draw(
+ getBoundingBox(
+ evn.x,
+ evn.y,
+ state.cursorSize,
+ state.cursorSize,
+ state.snapToGrid && basePixelCount
+ ),
+ "Interrogate",
+ {
+ w: stableDiffusionData.width,
+ h: stableDiffusionData.height,
+ },
+ {
+ sizeTextStyle: "#AFA5",
+ }
+ );
+ };
+
+ state.redraw = () => {
+ state.mousemovecb({
+ x: mouse.coords.world.pos.x,
+ y: mouse.coords.world.pos.y,
+ });
+ };
+
+ state.wheelcb = (evn) => {
+ _dream_onwheel(evn, state);
+ };
+
+ state.interrogatecb = (evn) => {
+ interrogate_callback(evn, state);
+ };
+ },
+ populateContextMenu: (menu, state) => {
+ if (!state.ctxmenu) {
+ state.ctxmenu = {};
+
+ // Cursor Size Slider
+ const cursorSizeSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/interrogate-cursorsize",
+ "cursorSize",
+ "Cursor Size",
+ {
+ min: 0,
+ max: 2048,
+ step: 128,
+ textStep: 2,
+ }
+ );
+
+ state.setCursorSize = cursorSizeSlider.setValue;
+ state.ctxmenu.cursorSizeSlider = cursorSizeSlider.slider;
+
+ // Snap to Grid Checkbox
+ state.ctxmenu.snapToGridLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/interrogate-snaptogrid",
+ "snapToGrid",
+ "Snap To Grid",
+ "icon-grid"
+ ).checkbox;
+ }
+
+ menu.appendChild(state.ctxmenu.cursorSizeSlider);
+ menu.appendChild(state.ctxmenu.snapToGridLabel);
+ },
+ shortcut: "N",
+ }
+ );
+
+const interrogate_callback = async (evn, state) => {
+ const bb = getBoundingBox(
+ evn.x,
+ evn.y,
+ state.cursorSize,
+ state.cursorSize,
+ state.snapToGrid && basePixelCount
+ );
+ // Do nothing if no image exists
+ const sectionCanvas = uil.getVisible({x: bb.x, y: bb.y, w: bb.w, h: bb.h});
+
+ if (isCanvasBlank(0, 0, bb.w, bb.h, sectionCanvas)) return;
+
+ // Build request to the API
+ const request = {};
+
+ // Temporary canvas for interrogated image
+ const auxCanvas = document.createElement("canvas");
+ auxCanvas.width = bb.w;
+ auxCanvas.height = bb.h;
+ const auxCtx = auxCanvas.getContext("2d");
+
+ auxCtx.fillStyle = "#000F";
+
+ // Get init image
+ auxCtx.fillRect(0, 0, bb.w, bb.h);
+ auxCtx.drawImage(sectionCanvas, 0, 0);
+ request.image = auxCanvas.toDataURL();
+
+ request.model = "clip"; //TODO maybe make a selectable option once A1111 supports the new openclip thingy?
+ const stopMarching = march(bb, {style: "#AFAF"});
+ try {
+ const result = await _interrogate(request);
+ const text = prompt(
+ result +
+ "\n\nDo you want to replace your prompt with this? You can change it down below:",
+ result
+ );
+ notifications.notify(`Interrogation returned '${result}'`, {
+ collapsed: true,
+ timeout: config.interrogateToolNotificationTimeout,
+ });
+ if (text) {
+ document.getElementById("prompt").value = text;
+ tools.dream.enable();
+ }
+ } finally {
+ stopMarching();
+ }
+};
+
+/**
+ * our private eye
+ *
+ * @param {StableDiffusionRequest} request Stable diffusion request
+ * @returns {Promise}
+ */
+const _interrogate = async (request) => {
+ const apiURL = `${host}${config.api.path}interrogate`;
+
+ /** @type {StableDiffusionResponse} */
+ let data = null;
+ try {
+ const response = await fetch(apiURL, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(request),
+ });
+
+ data = await response.json();
+ } finally {
+ }
+
+ return data.caption;
+};
diff --git a/openOutpaint-webUI-extension/app/js/ui/tool/maskbrush.js b/openOutpaint-webUI-extension/app/js/ui/tool/maskbrush.js
new file mode 100644
index 0000000000000000000000000000000000000000..080fe76f385b834ff1fed3a0afff30d9c6cd9163
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/ui/tool/maskbrush.js
@@ -0,0 +1,281 @@
+const setMask = (state) => {
+ const canvas = imageCollection.layers.mask.canvas;
+ switch (state) {
+ case "clear":
+ canvas.classList.remove("hold");
+ canvas.classList.add("display", "clear");
+ break;
+ case "hold":
+ canvas.classList.remove("clear");
+ canvas.classList.add("display", "hold");
+ break;
+ case "neutral":
+ canvas.classList.remove("clear", "hold");
+ canvas.classList.add("display");
+ break;
+ case "none":
+ canvas.classList.remove("display", "hold", "clear");
+ break;
+ default:
+ console.debug(`[maskbrush.setMask] Invalid mask type: ${state}`);
+ break;
+ }
+};
+
+const _mask_brush_draw_callback = (evn, state, opacity = 100) => {
+ maskPaintCtx.globalCompositeOperation = "source-over";
+ maskPaintCtx.strokeStyle = "black";
+
+ maskPaintCtx.filter =
+ "blur(" + state.brushBlur + "px) opacity(" + opacity + "%)";
+ maskPaintCtx.lineWidth = state.brushSize;
+ maskPaintCtx.beginPath();
+ maskPaintCtx.moveTo(
+ evn.px === undefined ? evn.x : evn.px,
+ evn.py === undefined ? evn.y : evn.py
+ );
+ maskPaintCtx.lineTo(evn.x, evn.y);
+ maskPaintCtx.lineJoin = maskPaintCtx.lineCap = "round";
+ maskPaintCtx.stroke();
+ maskPaintCtx.filter = null;
+};
+
+const _mask_brush_erase_callback = (evn, state, opacity = 100) => {
+ maskPaintCtx.globalCompositeOperation = "destination-out";
+ maskPaintCtx.strokeStyle = "black";
+
+ maskPaintCtx.filter = "blur(" + state.brushBlur + "px)";
+ maskPaintCtx.filter =
+ "blur(" + state.brushBlur + "px) opacity(" + opacity + "%)";
+ maskPaintCtx.lineWidth = state.brushSize;
+ maskPaintCtx.beginPath();
+ maskPaintCtx.moveTo(
+ evn.px === undefined ? evn.x : evn.px,
+ evn.py === undefined ? evn.y : evn.py
+ );
+ maskPaintCtx.lineTo(evn.x, evn.y);
+ maskPaintCtx.lineJoin = maskPaintCtx.lineCap = "round";
+ maskPaintCtx.stroke();
+ maskPaintCtx.filter = null;
+};
+
+const maskBrushTool = () =>
+ toolbar.registerTool(
+ "./res/icons/paintbrush.svg",
+ "Mask Brush",
+ (state, opt) => {
+ // Draw new cursor immediately
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+ state.redraw();
+
+ // Start Listeners
+ mouse.listen.world.onmousemove.on(state.movecb);
+ mouse.listen.world.onwheel.on(state.wheelcb);
+ mouse.listen.world.btn.left.onpaintstart.on(state.drawcb);
+ mouse.listen.world.btn.left.onpaint.on(state.drawcb);
+ mouse.listen.world.btn.right.onpaintstart.on(state.erasecb);
+ mouse.listen.world.btn.right.onpaint.on(state.erasecb);
+
+ // Display Mask
+ setMask("neutral");
+ },
+ (state, opt) => {
+ // Clear Listeners
+ mouse.listen.world.onmousemove.clear(state.movecb);
+ mouse.listen.world.onwheel.clear(state.wheelcb);
+ mouse.listen.world.btn.left.onpaintstart.clear(state.drawcb);
+ mouse.listen.world.btn.left.onpaint.clear(state.drawcb);
+ mouse.listen.world.btn.right.onpaintstart.clear(state.erasecb);
+ mouse.listen.world.btn.right.onpaint.clear(state.erasecb);
+
+ // Hide Mask
+ setMask("none");
+ state.ctxmenu.previewMaskButton.classList.remove("active");
+ maskPaintCanvas.classList.remove("opaque");
+ state.preview = false;
+
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+ },
+ {
+ init: (state) => {
+ state.config = {
+ brushScrollSpeed: 1 / 4,
+ minBrushSize: 10,
+ maxBrushSize: 500,
+ minBlur: 0,
+ maxBlur: 30,
+ };
+
+ state.brushSize = 64;
+ state.brushBlur = 0;
+ state.brushOpacity = 1;
+ state.block_res_change = true;
+ state.setBrushSize = (size) => {
+ state.brushSize = size;
+ state.ctxmenu.brushSizeRange.value = size;
+ state.ctxmenu.brushSizeText.value = size;
+ };
+
+ state.preview = false;
+
+ state.clearPrevCursor = () =>
+ uiCtx.clearRect(0, 0, uiCanvas.width, uiCanvas.height);
+
+ state.redraw = () => {
+ state.movecb({
+ ...mouse.coords.world.pos,
+ evn: {
+ clientX: mouse.coords.window.pos.x,
+ clientY: mouse.coords.window.pos.y,
+ },
+ });
+ };
+
+ state.movecb = (evn) => {
+ const vcp = {x: evn.evn.clientX, y: evn.evn.clientY};
+ const scp = state.brushSize / viewport.zoom;
+
+ state.clearPrevCursor();
+ state;
+ clearPrevCursor = () =>
+ uiCtx.clearRect(
+ vcp.x - scp / 2 - 10,
+ vcp.y - scp / 2 - 10,
+ vcp.x + scp / 2 + 10,
+ vcp.y + scp / 2 + 10
+ );
+
+ uiCtx.beginPath();
+ uiCtx.arc(vcp.x, vcp.y, scp / 2, 0, 2 * Math.PI, true);
+ uiCtx.strokeStyle = "black";
+ uiCtx.stroke();
+
+ uiCtx.beginPath();
+ uiCtx.arc(vcp.x, vcp.y, scp / 2, 0, 2 * Math.PI, true);
+ uiCtx.fillStyle = "#FFFFFF50";
+ uiCtx.fill();
+ };
+
+ state.redraw = () => {
+ state.movecb({
+ ...mouse.coords.world.pos,
+ evn: {
+ clientX: mouse.coords.window.pos.x,
+ clientY: mouse.coords.window.pos.y,
+ },
+ });
+ };
+
+ state.wheelcb = (evn) => {
+ state.brushSize = state.setBrushSize(
+ state.brushSize -
+ Math.floor(state.config.brushScrollSpeed * evn.delta)
+ );
+ state.redraw();
+ };
+
+ state.drawcb = (evn) =>
+ _mask_brush_draw_callback(evn, state, state.brushOpacity * 100);
+ state.erasecb = (evn) =>
+ _mask_brush_erase_callback(evn, state, state.brushOpacity * 100);
+ },
+ populateContextMenu: (menu, state) => {
+ if (!state.ctxmenu) {
+ state.ctxmenu = {};
+
+ // Brush size slider
+ const brushSizeSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/maskbrush-brushsize",
+ "brushSize",
+ "Brush Size",
+ {
+ min: state.config.minBrushSize,
+ max: state.config.maxBrushSize,
+ step: 5,
+ textStep: 1,
+ cb: (v) => {
+ if (!state.cursorLayer) return;
+
+ state.redraw();
+ },
+ }
+ );
+ state.ctxmenu.brushSizeSlider = brushSizeSlider.slider;
+ state.setBrushSize = brushSizeSlider.setValue;
+
+ // Brush opacity slider
+ const brushOpacitySlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/maskbrush-brushopacity",
+ "brushOpacity",
+ "Brush Opacity",
+ {
+ min: 0,
+ max: 1,
+ step: 0.05,
+ textStep: 0.001,
+ }
+ );
+ state.ctxmenu.brushOpacitySlider = brushOpacitySlider.slider;
+
+ // Brush blur slider
+ const brushBlurSlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/maskbrush-brushblur",
+ "brushBlur",
+ "Brush Blur",
+ {
+ min: state.config.minBlur,
+ max: state.config.maxBlur,
+ step: 1,
+ }
+ );
+ state.ctxmenu.brushBlurSlider = brushBlurSlider.slider;
+
+ // Some mask-related action buttons
+ const actionArray = document.createElement("div");
+ actionArray.classList.add("button-array");
+
+ const clearMaskButton = document.createElement("button");
+ clearMaskButton.classList.add("button", "tool");
+ clearMaskButton.textContent = "Clear";
+ clearMaskButton.title = "Clears Painted Mask";
+ clearMaskButton.onclick = () => {
+ maskPaintLayer.clear();
+ };
+
+ const previewMaskButton = document.createElement("button");
+ previewMaskButton.classList.add("button", "tool");
+ previewMaskButton.textContent = "Preview";
+ previewMaskButton.title = "Displays Mask with Full Opacity";
+ previewMaskButton.onclick = () => {
+ if (previewMaskButton.classList.contains("active")) {
+ maskPaintCanvas.classList.remove("opaque");
+ state.preview = false;
+
+ state.redraw();
+ } else {
+ maskPaintCanvas.classList.add("opaque");
+ state.preview = true;
+ state.redraw();
+ }
+ previewMaskButton.classList.toggle("active");
+ };
+
+ state.ctxmenu.previewMaskButton = previewMaskButton;
+
+ actionArray.appendChild(clearMaskButton);
+ actionArray.appendChild(previewMaskButton);
+
+ state.ctxmenu.actionArray = actionArray;
+ }
+
+ menu.appendChild(state.ctxmenu.brushSizeSlider);
+ menu.appendChild(state.ctxmenu.brushOpacitySlider);
+ menu.appendChild(state.ctxmenu.brushBlurSlider);
+ menu.appendChild(state.ctxmenu.actionArray);
+ },
+ shortcut: "M",
+ }
+ );
diff --git a/openOutpaint-webUI-extension/app/js/ui/tool/select.js b/openOutpaint-webUI-extension/app/js/ui/tool/select.js
new file mode 100644
index 0000000000000000000000000000000000000000..ef38bfc3a85ee4127f35579aa2d5eee3bd02d98e
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/ui/tool/select.js
@@ -0,0 +1,899 @@
+const selectTransformTool = () =>
+ toolbar.registerTool(
+ "./res/icons/box-select.svg",
+ "Select Image",
+ (state, opt) => {
+ // Draw new cursor immediately
+ ovLayer.clear();
+ state.movecb(mouse.coords.world.pos);
+
+ // Canvas left mouse handlers
+ mouse.listen.world.onmousemove.on(state.movecb);
+ mouse.listen.world.btn.left.onclick.on(state.clickcb);
+ mouse.listen.world.btn.left.ondragstart.on(state.dragstartcb);
+ mouse.listen.world.btn.left.ondrag.on(state.dragcb);
+ mouse.listen.world.btn.left.ondragend.on(state.dragendcb);
+
+ // Canvas right mouse handler
+ mouse.listen.world.btn.right.onclick.on(state.cancelcb);
+
+ // Keyboard click handlers
+ keyboard.listen.onkeyclick.on(state.keyclickcb);
+ keyboard.listen.onkeydown.on(state.keydowncb);
+
+ // Layer system handlers
+ uil.onactive.on(state.uilayeractivecb);
+
+ // Registers keyboard shortcuts
+ keyboard.onShortcut({ctrl: true, key: "KeyA"}, state.ctrlacb);
+ keyboard.onShortcut(
+ {ctrl: true, shift: true, key: "KeyA"},
+ state.ctrlsacb
+ );
+ keyboard.onShortcut({ctrl: true, key: "KeyC"}, state.ctrlccb);
+ keyboard.onShortcut({ctrl: true, key: "KeyV"}, state.ctrlvcb);
+ keyboard.onShortcut({ctrl: true, key: "KeyX"}, state.ctrlxcb);
+ keyboard.onShortcut({key: "Equal"}, state.togglemirror);
+
+ state.ctxmenu.mirrorSelectionCheckbox.disabled = true;
+ state.selected = null;
+
+ // Register Layer
+ state.originalDisplayLayer = imageCollection.registerLayer(null, {
+ after: uil.layer,
+ category: "select-display",
+ });
+ },
+ (state, opt) => {
+ // Clear all those listeners and shortcuts we set up
+ mouse.listen.world.onmousemove.clear(state.movecb);
+ mouse.listen.world.btn.left.onclick.clear(state.clickcb);
+ mouse.listen.world.btn.left.ondragstart.clear(state.dragstartcb);
+ mouse.listen.world.btn.left.ondrag.clear(state.dragcb);
+ mouse.listen.world.btn.left.ondragend.clear(state.dragendcb);
+
+ mouse.listen.world.btn.right.onclick.clear(state.cancelcb);
+
+ keyboard.listen.onkeyclick.clear(state.keyclickcb);
+ keyboard.listen.onkeydown.clear(state.keydowncb);
+ keyboard.deleteShortcut(state.ctrlacb, "KeyA");
+ keyboard.deleteShortcut(state.ctrlsacb, "KeyA");
+ keyboard.deleteShortcut(state.ctrlccb, "KeyC");
+ keyboard.deleteShortcut(state.ctrlvcb, "KeyV");
+ keyboard.deleteShortcut(state.ctrlxcb, "KeyX");
+ keyboard.deleteShortcut(state.togglemirror, "Equal");
+
+ uil.onactive.clear(state.uilayeractivecb);
+
+ // Clear any selections
+ state.reset();
+
+ // Resets cursor
+ ovLayer.clear();
+
+ // Clears overlay
+ imageCollection.inputElement.style.cursor = "auto";
+
+ // Delete Layer
+ imageCollection.deleteLayer(state.originalDisplayLayer);
+ state.originalDisplayLayer = null;
+ },
+ {
+ init: (state) => {
+ state.clipboard = {};
+
+ state.snapToGrid = true;
+ state.keepAspectRatio = true;
+ state.block_res_change = true;
+ state.useClipboard = !!(
+ navigator.clipboard && navigator.clipboard.write
+ ); // Use it by default if supported
+ state.selectionPeekOpacity = 40;
+
+ state.original = null;
+ state._selected = null;
+ Object.defineProperty(state, "selected", {
+ get: () => state._selected,
+ set: (v) => {
+ if (v) state.ctxmenu.enableButtons();
+ else state.ctxmenu.disableButtons();
+
+ return (state._selected = v);
+ },
+ });
+
+ // Some things to easy request for a redraw
+ state.lastMouseTarget = null;
+ state.lastMouseMove = {x: 0, y: 0};
+
+ state.redraw = () => {
+ ovLayer.clear();
+ state.movecb(state.lastMouseMove);
+ };
+
+ state.uilayeractivecb = ({uilayer}) => {
+ if (state.originalDisplayLayer) {
+ state.originalDisplayLayer.moveAfter(uilayer.layer);
+ }
+ };
+
+ /** @type {{selected: Point, offset: Point} | null} */
+ let moving = null;
+ /** @type {{handle: Point} | null} */
+ let scaling = null;
+ let rotating = false;
+
+ // Clears selection and make things right
+ state.reset = (erase = false) => {
+ if (state.selected && !erase)
+ state.original.layer.ctx.drawImage(
+ state.selected.canvas,
+ state.original.x,
+ state.original.y
+ );
+
+ if (state.originalDisplayLayer) {
+ state.originalDisplayLayer.clear();
+ }
+
+ if (state.dragging) state.dragging = null;
+ else {
+ state.ctxmenu.mirrorSelectionCheckbox.disabled = true;
+ state.selected = null;
+ }
+
+ state.mirrorSetValue(false);
+ state.rotation = 0;
+ state.original = null;
+ moving = null;
+ scaling = null;
+ rotating = null;
+
+ state.redraw();
+ };
+
+ // Selection Handlers
+ const selection = _tool._draggable_selection(state);
+
+ // UI Erasers
+ let eraseSelectedBox = () => null;
+ let eraseSelectedImage = () => null;
+ let eraseCursor = () => null;
+ let eraseSelection = () => null;
+
+ // Redraw UI
+ state.redrawui = () => {
+ // Get cursor positions
+ const {x, y, sx, sy} = _tool._process_cursor(
+ state.lastMouseMove,
+ state.snapToGrid
+ );
+
+ eraseSelectedBox();
+
+ if (state.selected) {
+ eraseSelectedBox = state.selected.drawBox(
+ uiCtx,
+ {x, y},
+ viewport.c2v
+ );
+ }
+ };
+
+ // Mirroring
+ state.togglemirror = () => {
+ state.mirrorSetValue(!state.mirrorSelection);
+ };
+
+ // Mouse Move Handler
+ state.movecb = (evn) => {
+ state.lastMouseMove = evn;
+
+ // Get cursor positions
+ const {x, y, sx, sy} = _tool._process_cursor(evn, state.snapToGrid);
+
+ // Erase Cursor
+ eraseSelectedBox();
+ eraseSelectedImage();
+ eraseSelection();
+ eraseCursor();
+ imageCollection.inputElement.style.cursor = "default";
+
+ // Draw Box and Selected Image
+ if (state.selected) {
+ eraseSelectedBox = state.selected.drawBox(
+ uiCtx,
+ {x, y},
+ viewport.c2v
+ );
+
+ if (
+ state.selected.hoveringBox(x, y) ||
+ state.selected.hoveringHandle(x, y, viewport.zoom).onHandle ||
+ state.selected.hoveringRotateHandle(x, y, viewport.zoom)
+ ) {
+ imageCollection.inputElement.style.cursor = "pointer";
+ }
+
+ eraseSelectedImage = state.selected.drawImage(
+ state.originalDisplayLayer.ctx,
+ ovCtx,
+ {opacity: state.selectionPeekOpacity / 100}
+ );
+ }
+
+ // Draw Selection
+ if (selection.exists) {
+ uiCtx.save();
+ uiCtx.setLineDash([2, 2]);
+ uiCtx.lineWidth = 2;
+ uiCtx.strokeStyle = "#FFF";
+
+ const bbvp = selection.bb.transform(viewport.c2v);
+ uiCtx.beginPath();
+ uiCtx.strokeRect(bbvp.x, bbvp.y, bbvp.w, bbvp.h);
+ uiCtx.stroke();
+
+ eraseSelection = () =>
+ uiCtx.clearRect(
+ bbvp.x - 10,
+ bbvp.y - 10,
+ bbvp.w + 20,
+ bbvp.h + 20
+ );
+
+ uiCtx.restore();
+ }
+
+ // Draw cursor
+ eraseCursor = _tool._cursor_draw(sx, sy);
+ };
+
+ // Handles left mouse clicks
+ state.clickcb = (evn) => {
+ if (
+ state.selected &&
+ !(
+ state.selected.rotation === 0 &&
+ state.selected.scale.x === 1 &&
+ state.selected.scale.y === 1 &&
+ state.selected.position.x === state.original.sx &&
+ state.selected.position.y === state.original.sy &&
+ !state.mirrorSelection &&
+ state.original.layer === uil.layer
+ ) &&
+ !isCanvasBlank(
+ 0,
+ 0,
+ state.selected.canvas.width,
+ state.selected.canvas.height,
+ state.selected.canvas
+ )
+ ) {
+ // Put original image back
+ state.original.layer.ctx.drawImage(
+ state.selected.canvas,
+ state.original.x,
+ state.original.y
+ );
+
+ // Erase Original Selection Area
+ commands.runCommand(
+ "eraseImage",
+ "Transform Tool Erase",
+ {
+ layer: state.original.layer,
+ x: state.original.x,
+ y: state.original.y,
+ w: state.selected.canvas.width,
+ h: state.selected.canvas.height,
+ },
+ {
+ extra: {
+ log: `Erased original selection area at x: ${state.original.x}, y: ${state.original.y}, width: ${state.selected.canvas.width}, height: ${state.selected.canvas.height} from layer ${state.original.layer.id}`,
+ },
+ }
+ );
+
+ // Draw Image
+ const {canvas, bb} = cropCanvas(state.originalDisplayLayer.canvas, {
+ border: 10,
+ });
+
+ let commandLog = "";
+ const addline = (v, newline = true) => {
+ commandLog += v;
+ if (newline) commandLog += "\n";
+ };
+
+ addline(
+ `Draw selected area to x: ${bb.x}, y: ${bb.y}, width: ${bb.w}, height: ${bb.h} to layer ${state.original.layer.id}`
+ );
+ addline(
+ ` - translation: (x: ${state.selected.position.x}, y: ${state.selected.position.y})`
+ );
+ addline(
+ ` - rotation : ${
+ Math.round(1000 * ((180 * state.selected.rotation) / Math.PI)) /
+ 1000
+ } degrees`,
+ false
+ );
+
+ commands.runCommand(
+ "drawImage",
+ "Transform Tool Apply",
+ {
+ image: canvas,
+ ...bb,
+ },
+ {
+ extra: {
+ log: commandLog,
+ },
+ }
+ );
+
+ state.reset(true);
+ } else {
+ state.reset();
+ }
+ };
+
+ // Handles left mouse drag start events
+ state.dragstartcb = (evn) => {
+ const {
+ x: ix,
+ y: iy,
+ sx: six,
+ sy: siy,
+ } = _tool._process_cursor({x: evn.ix, y: evn.iy}, state.snapToGrid);
+ const {x, y, sx, sy} = _tool._process_cursor(evn, state.snapToGrid);
+
+ if (state.selected) {
+ const hoveringBox = state.selected.hoveringBox(ix, iy);
+ const hoveringHandle = state.selected.hoveringHandle(
+ ix,
+ iy,
+ viewport.zoom
+ );
+ const hoveringRotateHandle = state.selected.hoveringRotateHandle(
+ ix,
+ iy,
+ viewport.zoom
+ );
+
+ if (hoveringBox) {
+ // Start dragging
+ moving = {
+ selected: state.selected.position,
+ offset: {
+ x: six - state.selected.position.x,
+ y: siy - state.selected.position.y,
+ },
+ };
+ return;
+ } else if (hoveringHandle.onHandle) {
+ // Start scaling
+ let handle = {x: 0, y: 0};
+
+ const lbb = new BoundingBox({
+ x: -state.selected.canvas.width / 2,
+ y: -state.selected.canvas.height / 2,
+ w: state.selected.canvas.width,
+ h: state.selected.canvas.height,
+ });
+
+ if (hoveringHandle.ontl) {
+ handle = lbb.tl;
+ } else if (hoveringHandle.ontr) {
+ handle = lbb.tr;
+ } else if (hoveringHandle.onbl) {
+ handle = lbb.bl;
+ } else {
+ handle = lbb.br;
+ }
+
+ scaling = {
+ handle,
+ };
+ return;
+ } else if (hoveringRotateHandle) {
+ rotating = true;
+ return;
+ }
+ }
+ selection.dragstartcb(evn);
+ };
+
+ const transform = (evn, x, y, sx, sy) => {
+ if (moving) {
+ state.selected.position = {
+ x: sx - moving.offset.x,
+ y: sy - moving.offset.y,
+ };
+ }
+
+ if (scaling) {
+ /** @type {DOMMatrix} */
+ const m = state.selected.rtmatrix.invertSelf();
+ const lscursor = m.transformPoint({x: sx, y: sy});
+
+ const xs = lscursor.x / scaling.handle.x;
+ const xy = lscursor.y / scaling.handle.y;
+
+ let xscale = 1;
+ let yscale = 1;
+
+ if (!state.keepAspectRatio) {
+ xscale = xs;
+ yscale = ys;
+ } else {
+ xscale = yscale = Math.max(xs, xy);
+ }
+
+ state.selected.scale = {x: xscale, y: yscale};
+ }
+
+ if (rotating) {
+ const center = state.selected.matrix.transformPoint({x: 0, y: 0});
+ let angle = Math.atan2(x - center.x, center.y - y);
+
+ if (evn.evn.shiftKey)
+ angle =
+ config.rotationSnappingAngles.find(
+ (v) => Math.abs(v - angle) < config.rotationSnappingDistance
+ ) ?? angle;
+
+ state.selected.rotation = angle;
+ }
+ };
+
+ // Handles left mouse drag events
+ state.dragcb = (evn) => {
+ const {x, y, sx, sy} = _tool._process_cursor(evn, state.snapToGrid);
+
+ if (state.selected) transform(evn, x, y, sx, sy);
+
+ if (selection.exists) selection.dragcb(evn);
+ };
+
+ // Handles left mouse drag end events
+
+ /** @type {(bb: BoundingBox) => void} */
+ const select = (bb) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = bb.w;
+ canvas.height = bb.h;
+ canvas
+ .getContext("2d")
+ .drawImage(uil.canvas, bb.x, bb.y, bb.w, bb.h, 0, 0, bb.w, bb.h);
+
+ uil.ctx.clearRect(bb.x, bb.y, bb.w, bb.h);
+
+ state.original = {
+ ...bb,
+ sx: bb.center.x,
+ sy: bb.center.y,
+ layer: uil.layer,
+ };
+ state.selected = new _tool.MarqueeSelection(canvas, bb.center);
+
+ state.ctxmenu.mirrorSelectionCheckbox.disabled = false;
+
+ state.redraw();
+ };
+
+ state.dragendcb = (evn) => {
+ const {x, y, sx, sy} = _tool._process_cursor(evn, state.snapToGrid);
+
+ if (selection.exists) {
+ selection.dragendcb(evn);
+
+ const bb = selection.bb;
+ state.backupBB = bb;
+
+ state.reset();
+
+ if (selection.exists && bb.w !== 0 && bb.h !== 0) select(bb);
+
+ selection.deselect();
+ }
+
+ if (state.selected) transform(evn, x, y, sx, sy);
+
+ moving = null;
+ scaling = null;
+ rotating = false;
+
+ state.redraw();
+ };
+
+ // Handler for right clicks. Basically resets everything
+ state.cancelcb = (evn) => {
+ state.reset();
+ };
+
+ // Keyboard callbacks (For now, they just handle the "delete" key)
+ state.keydowncb = (evn) => {};
+
+ state.keyclickcb = (evn) => {
+ switch (evn.code) {
+ case "Delete":
+ // Deletes selected area
+ state.selected &&
+ commands.runCommand(
+ "eraseImage",
+ "Erase Area",
+ state.selected,
+ {
+ extra: {
+ log: `[Placeholder] Delete selected area. TODO it's also broken`,
+ },
+ }
+ );
+ state.ctxmenu.mirrorSelectionCheckbox.disabled = true;
+ state.selected = null;
+ state.redraw();
+ }
+ };
+
+ // Register Ctrl-A Shortcut
+ state.ctrlacb = () => {
+ try {
+ const {bb} = cropCanvas(uil.canvas);
+ select(bb);
+ } catch (e) {
+ // Ignore errors
+ }
+ };
+
+ state.ctrlsacb = () => {
+ // Shift Key selects based on all visible layer information
+ const tl = {x: Infinity, y: Infinity};
+ const br = {x: -Infinity, y: -Infinity};
+
+ uil.layers.forEach(({layer}) => {
+ try {
+ const {bb} = cropCanvas(layer.canvas);
+
+ tl.x = Math.min(bb.tl.x, tl.x);
+ tl.y = Math.min(bb.tl.y, tl.y);
+
+ br.x = Math.max(bb.br.x, br.x);
+ br.y = Math.max(bb.br.y, br.y);
+ } catch (e) {
+ // Ignore errors
+ }
+ });
+
+ if (Number.isFinite(br.x - tl.y)) {
+ select(BoundingBox.fromStartEnd(tl, br));
+ }
+ };
+
+ // Register Ctrl-C/V Shortcut
+
+ // Handles copying
+ state.ctrlccb = (evn, cut = false) => {
+ if (!state.selected) return;
+
+ if (
+ isCanvasBlank(
+ 0,
+ 0,
+ state.selected.canvas.width,
+ state.selected.canvas.height,
+ state.selected.canvas
+ )
+ )
+ return;
+ // We create a new canvas to store the data
+ state.clipboard.copy = document.createElement("canvas");
+
+ state.clipboard.copy.width = state.selected.canvas.width;
+ state.clipboard.copy.height = state.selected.canvas.height;
+
+ const ctx = state.clipboard.copy.getContext("2d");
+
+ ctx.clearRect(0, 0, state.selected.w, state.selected.h);
+ ctx.drawImage(state.selected.canvas, 0, 0);
+
+ // If cutting, we reverse the selection and erase the selection area
+ if (cut) {
+ const aux = state.original;
+ state.reset();
+
+ commands.runCommand("eraseImage", "Cut Image", aux, {
+ extra: {
+ log: `Cut to clipboard a selected area at x: ${aux.x}, y: ${aux.y}, width: ${aux.w}, height: ${aux.h} from layer ${state.original.layer.id}`,
+ },
+ });
+ }
+
+ // Because firefox needs manual activation of the feature
+ if (state.useClipboard) {
+ // Send to clipboard
+ state.clipboard.copy.toBlob((blob) => {
+ const item = new ClipboardItem({"image/png": blob});
+ navigator.clipboard &&
+ navigator.clipboard.write([item]).catch((e) => {
+ console.warn("Error sending to clipboard");
+ console.warn(e);
+ });
+ });
+ }
+ };
+
+ // Handles pasting
+ state.ctrlvcb = async (evn) => {
+ if (state.useClipboard) {
+ // If we use the clipboard, do some proccessing of clipboard data (ugly but kind of minimum required)
+ navigator.clipboard &&
+ navigator.clipboard.read().then((items) => {
+ for (const item of items) {
+ for (const type of item.types) {
+ if (type.startsWith("image/")) {
+ item.getType(type).then(async (blob) => {
+ // Converts blob to image
+ const url = window.URL || window.webkitURL;
+ const image = document.createElement("img");
+ image.src = url.createObjectURL(blob);
+ await image.decode();
+ tools.stamp.enable({
+ image,
+ back: tools.selecttransform.enable,
+ });
+ });
+ }
+ }
+ }
+ });
+ } else if (state.clipboard.copy) {
+ // Use internal clipboard
+ const image = document.createElement("img");
+ image.src = state.clipboard.copy.toDataURL();
+ await image.decode();
+
+ // Send to stamp, as clipboard temporary data
+ tools.stamp.enable({
+ image,
+ back: tools.selecttransform.enable,
+ });
+ }
+ };
+
+ // Cut shortcut. Basically, send to copy handler
+ state.ctrlxcb = (evn) => {
+ state.ctrlccb(evn, true);
+ };
+ },
+ populateContextMenu: (menu, state) => {
+ if (!state.ctxmenu) {
+ state.ctxmenu = {};
+
+ // Snap To Grid Checkbox
+ state.ctxmenu.snapToGridLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/select-snaptogrid",
+ "snapToGrid",
+ "Snap To Grid",
+ "icon-grid"
+ ).checkbox;
+
+ // Keep Aspect Ratio
+ state.ctxmenu.keepAspectRatioLabel = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/select-keepaspectratio",
+ "keepAspectRatio",
+ "Keep Aspect Ratio",
+ "icon-maximize"
+ ).checkbox;
+
+ // Mirroring
+ state.onMirror = () => {
+ if (state.selected) {
+ const scale = state.selected.scale;
+ scale.x *= -1;
+ state.selected.scale = scale;
+
+ state.redraw();
+ }
+ };
+ const {checkbox: mirrorCheckbox, setValue: _mirrorSetValue} =
+ _toolbar_input.checkbox(
+ state,
+ "openoutpaint/select-mirror",
+ "mirrorSelection",
+ "Mirror Selection",
+ "icon-flip-horizontal",
+ (v) => {
+ state.onMirror();
+ }
+ );
+ state.ctxmenu.mirrorSelectionCheckbox = mirrorCheckbox;
+ state.ctxmenu.mirrorSelectionCheckbox.disabled = true;
+ _mirrorSetValue(false);
+ state.mirrorSetValue = (v) => {
+ _mirrorSetValue(v);
+ if (v !== state.mirrorSelection) {
+ state.onMirror();
+ }
+ };
+
+ // Use Clipboard
+ const clipboardCheckbox = _toolbar_input.checkbox(
+ state,
+ "openoutpaint/select-useclipboard",
+ "useClipboard",
+ "Use clipboard",
+ "icon-clipboard-list"
+ );
+ state.ctxmenu.useClipboardLabel = clipboardCheckbox.checkbox;
+ if (!(navigator.clipboard && navigator.clipboard.write))
+ clipboardCheckbox.checkbox.disabled = true; // Disable if not available
+
+ // Selection Peek Opacity
+ state.ctxmenu.selectionPeekOpacitySlider = _toolbar_input.slider(
+ state,
+ "openoutpaint/select-peekopacity",
+ "selectionPeekOpacity",
+ "Peek Opacity",
+ {
+ min: 0,
+ max: 100,
+ step: 10,
+ textStep: 1,
+ cb: () => {
+ state.redraw();
+ },
+ }
+ ).slider;
+
+ // Some useful actions to do with selection
+ const actionArray = document.createElement("div");
+ actionArray.classList.add("button-array");
+
+ // Save button
+ const saveSelectionButton = document.createElement("button");
+ saveSelectionButton.classList.add("button", "tool");
+ saveSelectionButton.textContent = "Save Sel.";
+ saveSelectionButton.title = "Saves Selection";
+ saveSelectionButton.onclick = () => {
+ downloadCanvas({
+ cropToContent: false,
+ canvas: state.selected.canvas,
+ });
+ };
+
+ // Save as Resource Button
+ const createResourceButton = document.createElement("button");
+ createResourceButton.classList.add("button", "tool");
+ createResourceButton.textContent = "Resource";
+ createResourceButton.title = "Saves Selection as a Resource";
+ createResourceButton.onclick = () => {
+ const image = document.createElement("img");
+ image.src = state.selected.canvas.toDataURL();
+ image.onload = () => {
+ tools.stamp.state.addResource("Selection Resource", image);
+ tools.stamp.enable();
+ };
+ };
+
+ actionArray.appendChild(saveSelectionButton);
+ actionArray.appendChild(createResourceButton);
+
+ // Some useful actions to do with selection
+ const visibleActionArray = document.createElement("div");
+ visibleActionArray.classList.add("button-array");
+
+ // Save Visible button
+ const saveVisibleSelectionButton = document.createElement("button");
+ saveVisibleSelectionButton.classList.add("button", "tool");
+ saveVisibleSelectionButton.textContent = "Save Vis.";
+ saveVisibleSelectionButton.title = "Saves Visible Selection";
+ saveVisibleSelectionButton.onclick = () => {
+ console.debug(state.selected);
+ console.debug(state.selected.bb);
+ var selectBB =
+ state.selected.bb != undefined
+ ? state.selected.bb
+ : state.backupBB;
+ const canvas = uil.getVisible(selectBB, {
+ categories: ["image", "user", "select-display"],
+ });
+ downloadCanvas({
+ cropToContent: false,
+ canvas,
+ });
+ };
+
+ // Save Visible as Resource Button
+ const createVisibleResourceButton = document.createElement("button");
+ createVisibleResourceButton.classList.add("button", "tool");
+ createVisibleResourceButton.textContent = "Vis. to Res.";
+ createVisibleResourceButton.title =
+ "Saves Visible Selection as a Resource";
+ createVisibleResourceButton.onclick = () => {
+ var selectBB =
+ state.selected.bb != undefined
+ ? state.selected.bb
+ : state.backupBB;
+ const canvas = uil.getVisible(selectBB, {
+ categories: ["image", "user", "select-display"],
+ });
+ const image = document.createElement("img");
+ image.src = canvas.toDataURL();
+ image.onload = () => {
+ tools.stamp.state.addResource("Selection Resource", image);
+ tools.stamp.enable();
+ };
+ };
+
+ visibleActionArray.appendChild(saveVisibleSelectionButton);
+ visibleActionArray.appendChild(createVisibleResourceButton);
+
+ // Disable buttons (if nothing is selected)
+ state.ctxmenu.disableButtons = () => {
+ saveSelectionButton.disabled = true;
+ createResourceButton.disabled = true;
+ saveVisibleSelectionButton.disabled = true;
+ createVisibleResourceButton.disabled = true;
+ };
+
+ // Enable buttons (if something is selected)
+ state.ctxmenu.enableButtons = () => {
+ saveSelectionButton.disabled = "";
+ createResourceButton.disabled = "";
+ saveVisibleSelectionButton.disabled = "";
+ createVisibleResourceButton.disabled = "";
+ };
+ state.ctxmenu.actionArray = actionArray;
+ state.ctxmenu.visibleActionArray = visibleActionArray;
+
+ // Send Selection to Destination
+ state.ctxmenu.sendSelected = document.createElement("select");
+ state.ctxmenu.sendSelected.style.width = "100%";
+ state.ctxmenu.sendSelected.addEventListener("change", (evn) => {
+ const v = evn.target.value;
+ if (state.selected && v !== "None")
+ global.webui && global.webui.sendTo(state.selected.canvas, v);
+ evn.target.value = "None";
+ });
+
+ let opt = document.createElement("option");
+ opt.textContent = "Send To...";
+ opt.value = "None";
+ state.ctxmenu.sendSelected.appendChild(opt);
+ }
+ const array = document.createElement("div");
+ array.classList.add("checkbox-array");
+ array.appendChild(state.ctxmenu.snapToGridLabel);
+ array.appendChild(state.ctxmenu.keepAspectRatioLabel);
+ array.appendChild(state.ctxmenu.mirrorSelectionCheckbox);
+ array.appendChild(state.ctxmenu.useClipboardLabel);
+ menu.appendChild(array);
+ menu.appendChild(state.ctxmenu.selectionPeekOpacitySlider);
+ menu.appendChild(state.ctxmenu.actionArray);
+ menu.appendChild(state.ctxmenu.visibleActionArray);
+ if (global.webui && global.webui.destinations) {
+ while (state.ctxmenu.sendSelected.lastChild.value !== "None") {
+ state.ctxmenu.sendSelected.removeChild(
+ state.ctxmenu.sendSelected.lastChild
+ );
+ }
+
+ global.webui.destinations.forEach((dst) => {
+ const opt = document.createElement("option");
+ opt.textContent = dst.name;
+ opt.value = dst.id;
+
+ state.ctxmenu.sendSelected.appendChild(opt);
+ });
+
+ menu.appendChild(state.ctxmenu.sendSelected);
+ }
+ },
+ shortcut: "S",
+ }
+ );
diff --git a/openOutpaint-webUI-extension/app/js/ui/tool/stamp.js b/openOutpaint-webUI-extension/app/js/ui/tool/stamp.js
new file mode 100644
index 0000000000000000000000000000000000000000..30f02971c8d8e716a66944ba5852e8b8945011ec
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/ui/tool/stamp.js
@@ -0,0 +1,694 @@
+/**
+ * Generic wheel handler
+ */
+let _stamp_wheel_accum = 0;
+
+const _stamp_onwheel = (evn, state) => {
+ if (evn.mode !== WheelEvent.DOM_DELTA_PIXEL) {
+ // We don't really handle non-pixel scrolling
+ return;
+ }
+
+ let delta = evn.delta;
+ if (evn.evn.shiftKey) delta *= 0.01;
+
+ // A simple but (I hope) effective fix for mouse wheel behavior
+ _stamp_wheel_accum += delta;
+
+ if (
+ !evn.evn.shiftKey &&
+ Math.abs(_stamp_wheel_accum) > config.wheelTickSize
+ ) {
+ // Snap to next or previous position
+ const v =
+ state.scale - 0.1 * (_stamp_wheel_accum / Math.abs(_stamp_wheel_accum));
+
+ state.setScale(v + snap(v, 0, 0.1));
+ state.redraw(evn);
+
+ _stamp_wheel_accum = 0; // Zero accumulation
+ } else if (evn.evn.shiftKey && Math.abs(_stamp_wheel_accum) >= 1) {
+ const v = state.scale - _stamp_wheel_accum * 0.01;
+ state.setScale(v);
+ state.redraw(evn);
+
+ _stamp_wheel_accum = 0; // Zero accumulation
+ }
+};
+
+const stampTool = () =>
+ toolbar.registerTool(
+ "./res/icons/file-up.svg",
+ "Stamp Image",
+ (state, opt) => {
+ state.loaded = true;
+
+ // Draw new cursor immediately
+ ovLayer.clear();
+ state.movecb({...mouse.coords.world.pos});
+
+ // Start Listeners
+ mouse.listen.world.onmousemove.on(state.movecb);
+ mouse.listen.world.btn.left.onclick.on(state.drawcb);
+ mouse.listen.world.btn.right.onclick.on(state.cancelcb);
+
+ mouse.listen.world.btn.left.ondragstart.on(state.dragstartcb);
+ mouse.listen.world.btn.left.ondrag.on(state.dragcb);
+ mouse.listen.world.btn.left.ondragend.on(state.dragendcb);
+
+ mouse.listen.world.onwheel.on(state.onwheelcb);
+ keyboard.onShortcut({key: "Minus"}, state.toggleflip);
+ keyboard.onShortcut({key: "Equal"}, state.togglemirror);
+
+ // For calls from other tools to paste image
+ if (opt && opt.image) {
+ state.addResource(
+ opt.name || "Clipboard",
+ opt.image,
+ opt.temporary === undefined ? true : opt.temporary,
+ false
+ );
+ state.ctxmenu.uploadButton.disabled = true;
+ state.back = opt.back || null;
+ toolbar.lock();
+ } else if (opt) {
+ throw Error(
+ "Pasting from other tools must be in format {image, name?, temporary?, back?}"
+ );
+ } else {
+ state.ctxmenu.uploadButton.disabled = "";
+ }
+ },
+ (state, opt) => {
+ state.loaded = false;
+
+ // Clear Listeners
+ mouse.listen.world.onmousemove.clear(state.movecb);
+ mouse.listen.world.btn.left.onclick.clear(state.drawcb);
+ mouse.listen.world.btn.right.onclick.clear(state.cancelcb);
+
+ mouse.listen.world.btn.left.ondragstart.clear(state.dragstartcb);
+ mouse.listen.world.btn.left.ondrag.clear(state.dragcb);
+ mouse.listen.world.btn.left.ondragend.clear(state.dragendcb);
+
+ mouse.listen.world.onwheel.clear(state.onwheelcb);
+ keyboard.deleteShortcut(state.togglemirror, "Equal");
+ keyboard.deleteShortcut(state.toggleflip, "Minus");
+
+ ovLayer.clear();
+ },
+ {
+ init: (state) => {
+ state.loaded = false;
+ state.snapToGrid = true;
+ state.resources = [];
+ state.selected = null;
+ state.back = null;
+
+ state.lastMouseMove = {x: 0, y: 0};
+ state.block_res_change = true;
+
+ // Current Rotation
+ let rotation = 0;
+ let rotating = null;
+ // Current Scale
+ state.scale = 1;
+
+ state.togglemirror = () => {
+ state.mirrorSetValue(!state.mirrorStamp);
+ state.redraw();
+ };
+
+ state.toggleflip = () => {
+ state.flipSetValue(!state.flipStamp);
+ state.redraw();
+ };
+
+ state.selectResource = (resource, nolock = true, deselect = true) => {
+ rotation = 0;
+ state.setScale(1);
+ if (nolock && state.ctxmenu.uploadButton.disabled) return;
+
+ state.mirrorSetValue(false);
+ state.flipSetValue(false);
+
+ console.debug(
+ `[stamp] Selecting Resource '${resource && resource.name}'[${
+ resource && resource.id
+ }]`
+ );
+
+ const resourceWrapper = resource && resource.dom.wrapper;
+
+ const wasSelected =
+ resourceWrapper && resourceWrapper.classList.contains("active");
+
+ Array.from(state.ctxmenu.resourceList.children).forEach((child) => {
+ child.classList.remove("active");
+ });
+
+ // Select
+ if (!wasSelected) {
+ resourceWrapper && resourceWrapper.classList.add("active");
+ state.selected = resource;
+ }
+ // If already selected, clear selection (if deselection is enabled)
+ else if (deselect) {
+ resourceWrapper.classList.remove("active");
+ state.selected = null;
+ }
+
+ ovLayer.clear();
+ if (state.loaded) state.redraw();
+ };
+
+ // Open IndexedDB connection
+ // Synchronizes resources array with the DOM and Local Storage
+ const syncResources = () => {
+ // Saves to IndexedDB
+ const resources = db
+ .transaction("resources", "readwrite")
+ .objectStore("resources");
+ try {
+ const FetchKeysQuery = resources.getAllKeys();
+ FetchKeysQuery.onsuccess = () => {
+ const keys = FetchKeysQuery.result;
+ keys.forEach((key) => {
+ if (!state.resources.find((resource) => resource.id === key))
+ resources.delete(key);
+ });
+ };
+
+ state.resources
+ .filter((resource) => !resource.temporary && resource.dirty)
+ .forEach((resource) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = resource.image.width;
+ canvas.height = resource.image.height;
+
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(resource.image, 0, 0);
+
+ resources.put({
+ id: resource.id,
+ name: resource.name,
+ src: canvas.toDataURL(),
+ });
+
+ resource.dirty = false;
+ });
+ } catch (e) {
+ console.warn(
+ "[stamp] Failed to synchronize resources with IndexedDB"
+ );
+ console.warn(e);
+ }
+
+ // Creates DOM elements when needed
+ state.resources.forEach((resource) => {
+ if (
+ !state.ctxmenu.resourceList.querySelector(
+ `#resource-${resource.id}`
+ )
+ ) {
+ console.debug(
+ `[stamp] Creating Resource Element [resource-${resource.id}]`
+ );
+ const resourceWrapper = document.createElement("div");
+ resourceWrapper.id = `resource-${resource.id}`;
+ resourceWrapper.title = resource.name;
+ resourceWrapper.classList.add("resource", "list-item");
+ const resourceTitle = document.createElement("input");
+ resourceTitle.value = resource.name;
+ resourceTitle.style.pointerEvents = "none";
+ resourceTitle.addEventListener("change", () => {
+ resource.name = resourceTitle.value;
+ resource.dirty = true;
+ resourceWrapper.title = resourceTitle.value;
+
+ syncResources();
+ });
+ resourceTitle.addEventListener("keyup", function (event) {
+ if (event.key === "Enter") {
+ resourceTitle.blur();
+ }
+ });
+
+ resourceTitle.addEventListener("blur", () => {
+ resourceTitle.style.pointerEvents = "none";
+ });
+ resourceTitle.classList.add("resource-title", "title");
+
+ resourceWrapper.appendChild(resourceTitle);
+
+ resourceWrapper.addEventListener("click", () =>
+ state.selectResource(resource)
+ );
+
+ resourceWrapper.addEventListener("dblclick", () => {
+ resourceTitle.style.pointerEvents = "auto";
+ resourceTitle.focus();
+ resourceTitle.select();
+ });
+
+ resourceWrapper.addEventListener("mouseover", () => {
+ state.ctxmenu.previewPane.style.display = "block";
+ state.ctxmenu.previewPane.style.backgroundImage = `url(${resource.image.src})`;
+ });
+ resourceWrapper.addEventListener("mouseleave", () => {
+ state.ctxmenu.previewPane.style.display = "none";
+ });
+
+ // Add action buttons
+ const actionArray = document.createElement("div");
+ actionArray.classList.add("actions");
+
+ const saveButton = document.createElement("button");
+ saveButton.addEventListener(
+ "click",
+ (evn) => {
+ evn.stopPropagation();
+ const canvas = document.createElement("canvas");
+ canvas.width = resource.image.width;
+ canvas.height = resource.image.height;
+ canvas.getContext("2d").drawImage(resource.image, 0, 0);
+
+ downloadCanvas({
+ canvas,
+ filename: `openOutpaint - resource '${resource.name}'.png`,
+ });
+ },
+ {passive: false}
+ );
+ saveButton.title = "Download Resource";
+ saveButton.appendChild(document.createElement("div"));
+ saveButton.classList.add("download-btn");
+
+ const trashButton = document.createElement("button");
+ trashButton.addEventListener(
+ "click",
+ (evn) => {
+ evn.stopPropagation();
+ state.ctxmenu.previewPane.style.display = "none";
+ state.deleteResource(resource.id);
+ },
+ {passive: false}
+ );
+ trashButton.title = "Delete Resource";
+ trashButton.appendChild(document.createElement("div"));
+ trashButton.classList.add("delete-btn");
+
+ actionArray.appendChild(saveButton);
+ actionArray.appendChild(trashButton);
+ resourceWrapper.appendChild(actionArray);
+ state.ctxmenu.resourceList.appendChild(resourceWrapper);
+ resource.dom = {wrapper: resourceWrapper};
+ }
+ });
+
+ // Removes DOM elements when needed
+ const elements = Array.from(state.ctxmenu.resourceList.children);
+
+ if (elements.length > state.resources.length)
+ elements.forEach((element) => {
+ let remove = true;
+ state.resources.some((resource) => {
+ if (element.id.endsWith(resource.id)) {
+ remove = false;
+ }
+ });
+
+ if (remove) {
+ console.debug(`[stamp] Sync Removing Element [${element.id}]`);
+ state.ctxmenu.resourceList.removeChild(element);
+ }
+ });
+ };
+
+ // Adds a image resource (temporary allows only one draw, used for pasting)
+ state.addResource = (name, image, temporary = false, nolock = true) => {
+ const id = guid();
+ const resource = {
+ id,
+ name,
+ image,
+ dirty: true,
+ temporary,
+ };
+
+ console.info(`[stamp] Adding Resource '${name}'[${id}]`);
+
+ state.resources.push(resource);
+ syncResources();
+
+ // Select this resource
+ state.selectResource(resource, nolock, false);
+
+ return resource;
+ };
+
+ // Used for temporary images too
+ state.deleteResource = (id) => {
+ const resourceIndex = state.resources.findIndex((v) => v.id === id);
+ const resource = state.resources[resourceIndex];
+ if (state.selected === resource) state.selected = null;
+ console.info(
+ `[stamp] Deleting Resource '${resource.name}'[${resource.id}]`
+ );
+
+ state.resources.splice(resourceIndex, 1);
+
+ syncResources();
+ };
+
+ state.onwheelcb = (evn) => {
+ _stamp_onwheel(evn, state);
+ };
+
+ state.dragstartcb = (evn) => {
+ const {x, y, sx, sy} = _tool._process_cursor(evn, state.snapToGrid);
+ rotating = {x: sx, y: sy};
+ };
+
+ state.dragcb = (evn) => {
+ if (rotating) {
+ if (state.flipStamp) {
+ rotation = Math.atan2(evn.x - rotating.x, evn.y - rotating.y);
+ } else {
+ rotation = Math.atan2(rotating.x - evn.x, evn.y - rotating.y);
+ }
+
+ if (evn.evn.shiftKey)
+ rotation =
+ config.rotationSnappingAngles.find(
+ (v) =>
+ Math.abs(v - rotation) < config.rotationSnappingDistance
+ ) ?? rotation;
+ }
+ };
+
+ state.dragendcb = (evn) => {
+ rotating = null;
+ };
+
+ let erasePrevCursor = () => null;
+
+ state.movecb = (evn) => {
+ const {x, y, sx, sy} = _tool._process_cursor(evn, state.snapToGrid);
+
+ // Erase Previous Cursors
+ erasePrevCursor();
+
+ state.lastMouseMove = evn;
+
+ ovLayer.clear();
+
+ let px = sx;
+ let py = sy;
+
+ if (rotating) {
+ px = rotating.x;
+ py = rotating.y;
+ }
+
+ // Draw selected image
+ if (state.selected) {
+ ovCtx.save();
+ ovCtx.translate(px, py);
+ ovCtx.scale(
+ state.scale * (state.mirrorStamp ? -1 : 1),
+ state.scale * (state.flipStamp ? -1 : 1) //flip vertical?????
+ );
+ ovCtx.rotate(rotation * (state.mirrorStamp ? -1 : 1));
+
+ ovCtx.drawImage(state.selected.image, 0, 0);
+ ovCtx.restore();
+ }
+
+ // Draw current cursor location
+ erasePrevCursor = _tool._cursor_draw(px, py);
+ };
+
+ state.redraw = () => {
+ state.movecb(state.lastMouseMove);
+ };
+
+ state.drawcb = (evn) => {
+ const {x, y, sx, sy} = _tool._process_cursor(evn, state.snapToGrid);
+
+ const resource = state.selected;
+
+ if (resource) {
+ if (
+ localStorage.getItem("openoutpaint/settings.autolayer") == "true"
+ ) {
+ commands.runCommand("addLayer", "Added Layer", {});
+ }
+ const {canvas, bb} = cropCanvas(ovCanvas, {border: 10});
+
+ let commandLog = "";
+
+ const addline = (v, newline = true) => {
+ commandLog += v;
+ if (newline) commandLog += "\n";
+ };
+ addline(
+ `Stamped image '${resource.name}' to x: ${bb.x} and y: ${bb.y}`
+ );
+ addline(` - scaling : ${state.scale}`);
+ addline(
+ ` - rotation: ${
+ Math.round(1000 * ((180 * rotation) / Math.PI)) / 1000
+ } degrees`,
+ false
+ );
+
+ commands.runCommand(
+ "drawImage",
+ "Image Stamp",
+ {
+ image: canvas,
+ x: bb.x,
+ y: bb.y,
+ },
+ {
+ extra: {
+ log: commandLog,
+ },
+ }
+ );
+
+ if (resource.temporary) {
+ state.deleteResource(resource.id);
+ }
+ }
+
+ if (state.back) {
+ toolbar.unlock();
+ const backfn = state.back;
+ state.back = null;
+ backfn({message: "Returning from stamp", pasted: true});
+ }
+ };
+ state.cancelcb = (evn) => {
+ state.selectResource(null);
+
+ if (state.back) {
+ toolbar.unlock();
+ const backfn = state.back;
+ state.back = null;
+ backfn({message: "Returning from stamp", pasted: false});
+ }
+ };
+
+ /**
+ * Creates context menu
+ */
+ if (!state.ctxmenu) {
+ state.ctxmenu = {};
+ // Snap To Grid Checkbox
+ const array = document.createElement("div");
+ array.classList.add("checkbox-array");
+ array.appendChild(
+ _toolbar_input.checkbox(
+ state,
+ "openoutpaint/stamp-snaptogrid",
+ "snapToGrid",
+ "Snap To Grid",
+ "icon-grid"
+ ).checkbox
+ );
+
+ // Flip Stamp Checkbox
+ const {checkbox: flipCheckbox, setValue: flipSetValue} =
+ _toolbar_input.checkbox(
+ state,
+ "openoutpaint/stamp-flip",
+ "flipStamp",
+ "Flip Stamp",
+ "icon-flip-vertical"
+ );
+ array.appendChild(flipCheckbox);
+ state.flipSetValue = flipSetValue;
+
+ // Mirror Stamp Checkbox
+ const {checkbox: mirrorCheckbox, setValue: mirrorSetValue} =
+ _toolbar_input.checkbox(
+ state,
+ "openoutpaint/stamp-mirror",
+ "mirrorStamp",
+ "Mirror Stamp",
+ "icon-flip-horizontal"
+ );
+ array.appendChild(mirrorCheckbox);
+ state.mirrorSetValue = mirrorSetValue;
+
+ state.ctxmenu.buttonArray = array;
+
+ // Scale Slider
+ const scaleSlider = _toolbar_input.slider(
+ state,
+ null,
+ "scale",
+ "Scale",
+ {
+ min: 0.01,
+ max: 10,
+ step: 0.1,
+ textStep: 0.001,
+ }
+ );
+ state.ctxmenu.scaleSlider = scaleSlider.slider;
+ state.setScale = scaleSlider.setValue;
+
+ // Create resource list
+ const uploadButtonId = `upload-btn-${guid()}`;
+
+ const resourceManager = document.createElement("div");
+ resourceManager.classList.add("resource-manager");
+ const resourceList = document.createElement("div");
+ resourceList.classList.add("list");
+
+ const previewPane = document.createElement("div");
+ previewPane.classList.add("preview-pane");
+
+ const uploadLabel = document.createElement("label");
+ uploadLabel.classList.add("upload-button");
+ uploadLabel.classList.add("button");
+ uploadLabel.classList.add("tool");
+ uploadLabel.textContent = "Upload Image";
+ uploadLabel.htmlFor = uploadButtonId;
+ const uploadButton = document.createElement("input");
+ uploadButton.id = uploadButtonId;
+ uploadButton.type = "file";
+ uploadButton.accept = "image/*";
+ uploadButton.multiple = true;
+ uploadButton.style.display = "none";
+
+ uploadButton.addEventListener("change", (evn) => {
+ [...uploadButton.files].forEach((file) => {
+ if (file.type.startsWith("image/")) {
+ console.info("Uploading Image " + file.name);
+ const url = window.URL || window.webkitURL;
+ const image = document.createElement("img");
+ image.src = url.createObjectURL(file);
+
+ image.onload = () => state.addResource(file.name, image, false);
+ }
+ });
+ uploadButton.value = null;
+ });
+
+ uploadLabel.appendChild(uploadButton);
+ resourceManager.appendChild(resourceList);
+ resourceManager.appendChild(uploadLabel);
+ resourceManager.appendChild(previewPane);
+
+ resourceManager.addEventListener(
+ "drop",
+ (evn) => {
+ evn.preventDefault();
+ resourceManager.classList.remove("dragging");
+
+ if (evn.dataTransfer.items) {
+ Array.from(evn.dataTransfer.items).forEach((item) => {
+ if (item.kind === "file" && item.type.startsWith("image/")) {
+ const file = item.getAsFile();
+ const url = window.URL || window.webkitURL;
+ const image = document.createElement("img");
+ image.src = url.createObjectURL(file);
+
+ state.addResource(file.name, image, false);
+ }
+ });
+ }
+ },
+ {passive: false}
+ );
+ resourceManager.addEventListener(
+ "dragover",
+ (evn) => {
+ evn.preventDefault();
+ },
+ {passive: false}
+ );
+
+ resourceManager.addEventListener("dragover", (evn) => {
+ resourceManager.classList.add("dragging");
+ });
+
+ resourceManager.addEventListener("dragover", (evn) => {
+ resourceManager.classList.remove("dragging");
+ });
+
+ state.ctxmenu.uploadButton = uploadButton;
+ state.ctxmenu.previewPane = previewPane;
+ state.ctxmenu.resourceManager = resourceManager;
+ state.ctxmenu.resourceList = resourceList;
+
+ // Performs resource fetch from IndexedDB
+ const loadResources = async () => {
+ console.debug("[stamp] Connected to IndexedDB");
+
+ /** @type {IDBRequest<{id: string, name: string, src: string}[]>} */
+ const FetchAllTransaction = db
+ .transaction("resources")
+ .objectStore("resources")
+ .getAll();
+
+ FetchAllTransaction.onsuccess = async () => {
+ const data = FetchAllTransaction.result;
+
+ state.resources.push(
+ ...(await Promise.all(
+ data.map((resource) => {
+ const image = document.createElement("img");
+ image.src = resource.src;
+
+ return new Promise((resolve, reject) => {
+ image.onload = () =>
+ resolve({
+ id: resource.id,
+ name: resource.name,
+ image,
+ });
+ });
+ })
+ ))
+ );
+ syncResources();
+ };
+ };
+
+ if (db) loadResources();
+ else ondatabaseload.on(loadResources);
+ }
+ },
+ populateContextMenu: (menu, state) => {
+ menu.appendChild(state.ctxmenu.buttonArray);
+ menu.appendChild(state.ctxmenu.scaleSlider);
+ menu.appendChild(state.ctxmenu.resourceManager);
+ },
+ shortcut: "U",
+ }
+ );
diff --git a/openOutpaint-webUI-extension/app/js/webui.js b/openOutpaint-webUI-extension/app/js/webui.js
new file mode 100644
index 0000000000000000000000000000000000000000..fde3be105712dd917952de5e3f1b90fc92595c19
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/js/webui.js
@@ -0,0 +1,191 @@
+/**
+ * This file should only be actually loaded if we are in a trusted environment.
+ */
+(async () => {
+ let parentWindow = null;
+ const webui = {
+ /** @type {{name: string, id: string}[]} */
+ destinations: null,
+
+ /**
+ * Sends a
+ *
+ * @param {HTMLCanvas} canvas Canvas to send the data of
+ * @param {string} destination The ID of the destination
+ */
+ sendTo(canvas, destination) {
+ if (!this.destinations.find((d) => d.id === destination))
+ throw new Error("[webui] Given destination is not available");
+
+ parentWindow &&
+ parentWindow.postMessage({
+ type: "openoutpaint/sendto",
+ message: {
+ image: canvas.toDataURL(),
+ destination,
+ },
+ });
+ },
+ };
+
+ // Check if key file exists
+ const response = await fetch("key.json");
+
+ /** @type {{host?: string, trusted?: boolean, key: string}} */
+ let data = null;
+ if (response.status === 200) {
+ data = await response.json();
+ console.info("[webui] key.json loaded successfully");
+ }
+ if (response.status !== 200 || (!data.key && !data.trusted)) {
+ console.warn(
+ "[webui] An accessible key.json file with a 'key' or 'trusted' should be provided to allow for messaging"
+ );
+ console.warn(data);
+ return;
+ }
+
+ const key = data.key;
+
+ // Check if we are running inside an iframe or embed
+ try {
+ const frame = window.frameElement;
+
+ if (frame === null) {
+ console.info("[webui] Not running inside a frame");
+ } else {
+ console.info(
+ `[webui] Window is child of '${window.parent.document.URL}'`
+ );
+ if (data.host && !window.parent.document.URL.startsWith(data.host)) {
+ console.warn(
+ `[webui] Window does not trust parent '${window.parent.document.URL}'`
+ );
+ console.warn("[webui] Will NOT setup message listeners");
+ return;
+ }
+ }
+ } catch (e) {
+ console.warn(
+ `[webui] Running in a third party iframe or embed, and blocked by CORS`
+ );
+ console.warn(e);
+ return;
+ }
+
+ if (data) {
+ if (!data.trusted) console.debug(`[webui] Loaded key`);
+
+ window.addEventListener("message", ({data, origin, source}) => {
+ if (!data.trusted && data.key !== key) {
+ console.warn(
+ `[webui] Message with incorrect key was received from '${origin}'`
+ );
+ console.warn(data);
+ return;
+ }
+
+ if (!parentWindow && !data.type === "openoutpaint/init") {
+ console.warn(`[webui] Communication has not been initialized`);
+ }
+
+ if (global.debug) {
+ console.debug("[webui] Received message:");
+ console.debug(data);
+ }
+
+ try {
+ switch (data.type) {
+ case "openoutpaint/init":
+ parentWindow = source;
+ console.debug(
+ `[webui] Communication with '${origin}' has been initialized`
+ );
+ if (data.host)
+ setFixedHost(
+ data.host,
+ `Are you sure you want to modify the host? This configuration was provided by the hosting page: - ${parentWindow.document.title} (${origin})`
+ );
+ if (data.destinations) webui.destinations = data.destinations;
+
+ break;
+ case "openoutpaint/add-resource":
+ {
+ const image = document.createElement("img");
+ image.src = data.image.dataURL;
+ image.onload = async () => {
+ await tools.stamp.state.addResource(
+ data.image.resourceName || "External Resource",
+ image
+ );
+ // Fit image on screen if too big
+ const wr = image.width / window.innerWidth;
+ const hr = image.height / window.innerHeight;
+ const mr = Math.max(wr, hr);
+
+ if (mr > viewport.zoom) {
+ viewport.zoom = mr * 1.3;
+ viewport.transform(imageCollection.element);
+
+ toolbar._current_tool.redrawui &&
+ toolbar._current_tool.redrawui();
+ }
+
+ tools.stamp.enable();
+ };
+ }
+ break;
+ case "openoutpaint/set-prompt":
+ {
+ const promptEl = document.getElementById("prompt");
+ const negativePromptEl = document.getElementById("negPrompt");
+
+ if (data.prompt !== undefined) {
+ promptEl.value = data.prompt;
+ stableDiffusionData.prompt = promptEl.value;
+ promptEl.title = promptEl.value;
+ localStorage.setItem(
+ "openoutpaint/prompt",
+ stableDiffusionData.prompt
+ );
+ }
+
+ if (data.negPrompt !== undefined) {
+ negativePromptEl.value = data.negPrompt;
+ stableDiffusionData.negative_prompt = negativePromptEl.value;
+ negativePromptEl.title = negativePromptEl.value;
+ localStorage.setItem(
+ "openoutpaint/neg_prompt",
+ stableDiffusionData.negative_prompt
+ );
+ }
+
+ if (data.styles !== undefined) {
+ styleSelectElement.value = data.styles;
+ }
+ }
+ break;
+ default:
+ console.warn(`[webui] Unsupported message type: ${data.type}`);
+ break;
+ }
+
+ // Send acknowledgement
+ parentWindow &&
+ parentWindow.postMessage({
+ type: "openoutpaint/ack",
+ message: data,
+ });
+ } catch (e) {
+ console.warn(
+ `[webui] Message of type '${data.type}' has invalid format`
+ );
+ console.warn(e);
+ console.warn(data);
+ }
+ });
+ }
+ return webui;
+})().then((value) => {
+ global.webui = value;
+});
diff --git a/openOutpaint-webUI-extension/app/openOutpaint.bat b/openOutpaint-webUI-extension/app/openOutpaint.bat
new file mode 100644
index 0000000000000000000000000000000000000000..99e265fa14efccc9b3238e77ffc2d155ca31aa50
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/openOutpaint.bat
@@ -0,0 +1,2 @@
+@echo off
+python -m http.server -b 0.0.0.0 3456
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/openOutpaint.sh b/openOutpaint-webUI-extension/app/openOutpaint.sh
new file mode 100644
index 0000000000000000000000000000000000000000..350215072c16b677800b4d19b774c63342fc231b
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/openOutpaint.sh
@@ -0,0 +1 @@
+python -m http.server 3456
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/pages/configuration.html b/openOutpaint-webUI-extension/app/pages/configuration.html
new file mode 100644
index 0000000000000000000000000000000000000000..7e9b99013b22265e32f29bbcc57e84e84c0e8467
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/pages/configuration.html
@@ -0,0 +1,286 @@
+
+
+
+
+ openOutpaint 🐠
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Canvas Size
+
+
+ x
+
+
+
+
+
+
Max Steps
+
+
+
+
+
+
+
CFG minmax
+
+
+ ::
+
+
+
+
+
+
+
+
+
+
+
+
Lie to HRfix
+
+
+
+
+
+
New Layer per Dream
+
+
+
+
+
+
Smooth Rendering
+
+
+
+
+
+
+ Button Updates Prompt
+
+
+
+
+
+
Jump to 1st New on +
+
+
+
+
+
+
+
+
+
+
diff --git a/openOutpaint-webUI-extension/app/pages/embed.test.html b/openOutpaint-webUI-extension/app/pages/embed.test.html
new file mode 100644
index 0000000000000000000000000000000000000000..420c634629d021642014befa22eb6c37a4c910e1
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/pages/embed.test.html
@@ -0,0 +1,74 @@
+
+
+
+
+ openOutpaint embed
+
+
+
+
+
+
+
diff --git a/openOutpaint-webUI-extension/app/pages/favicon.ico b/openOutpaint-webUI-extension/app/pages/favicon.ico
new file mode 100644
index 0000000000000000000000000000000000000000..a0ea5e09824686f5c632568c17421dbf3d986292
Binary files /dev/null and b/openOutpaint-webUI-extension/app/pages/favicon.ico differ
diff --git a/openOutpaint-webUI-extension/app/res/fonts/OpenSans.ttf b/openOutpaint-webUI-extension/app/res/fonts/OpenSans.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..79a43850d2041344b437e7e58f7b2fc77c954a2c
Binary files /dev/null and b/openOutpaint-webUI-extension/app/res/fonts/OpenSans.ttf differ
diff --git a/openOutpaint-webUI-extension/app/res/icons/LICENSE b/openOutpaint-webUI-extension/app/res/icons/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..cee53ae3ad195ef84ca06c63be211d4c090ef6fa
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/LICENSE
@@ -0,0 +1,7 @@
+ISC License
+
+Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
+
+Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
diff --git a/openOutpaint-webUI-extension/app/res/icons/box-select.svg b/openOutpaint-webUI-extension/app/res/icons/box-select.svg
new file mode 100644
index 0000000000000000000000000000000000000000..3d6affd463825354edcbbbc80d218d87df7c8e39
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/box-select.svg
@@ -0,0 +1,15 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/brush.svg b/openOutpaint-webUI-extension/app/res/icons/brush.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a4ddbd13c3c9784b92662dde75e1ffc76ccb6027
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/brush.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/check.svg b/openOutpaint-webUI-extension/app/res/icons/check.svg
new file mode 100644
index 0000000000000000000000000000000000000000..16acfeb962f4d7939fa043e60130a08b53fe5a71
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/check.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/chevron-down.svg b/openOutpaint-webUI-extension/app/res/icons/chevron-down.svg
new file mode 100644
index 0000000000000000000000000000000000000000..367a2bbce0cbb917c82e0947fa0fe3850ed0b269
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/chevron-down.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/chevron-first.svg b/openOutpaint-webUI-extension/app/res/icons/chevron-first.svg
new file mode 100644
index 0000000000000000000000000000000000000000..36cfa87f9a10a1fa300ea9a4aeced2d560a52682
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/chevron-first.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/chevron-up.svg b/openOutpaint-webUI-extension/app/res/icons/chevron-up.svg
new file mode 100644
index 0000000000000000000000000000000000000000..7bfc938e68d3e85c37a102b8a4a4713890c5a755
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/chevron-up.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/clipboard-list.svg b/openOutpaint-webUI-extension/app/res/icons/clipboard-list.svg
new file mode 100644
index 0000000000000000000000000000000000000000..45bbcf55662675087c176399653eae900c0562bc
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/clipboard-list.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/download.svg b/openOutpaint-webUI-extension/app/res/icons/download.svg
new file mode 100644
index 0000000000000000000000000000000000000000..e04900eed2cf616458f34f739d493132232f0fea
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/download.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/edit.svg b/openOutpaint-webUI-extension/app/res/icons/edit.svg
new file mode 100644
index 0000000000000000000000000000000000000000..aafb5ce96a8c3ebd834b50ae3022a2bf21c7877a
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/edit.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/equal.svg b/openOutpaint-webUI-extension/app/res/icons/equal.svg
new file mode 100644
index 0000000000000000000000000000000000000000..6b5c5c8d76bfd39441a0f21140b59fcfbf60c9bc
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/equal.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/expand.svg b/openOutpaint-webUI-extension/app/res/icons/expand.svg
new file mode 100644
index 0000000000000000000000000000000000000000..6f5864ad58bb66d8f5f6236f5a29e94620427d09
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/expand.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/eye-off.svg b/openOutpaint-webUI-extension/app/res/icons/eye-off.svg
new file mode 100644
index 0000000000000000000000000000000000000000..995e056798873e364d29923f9b917571dfd214ad
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/eye-off.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/eye.svg b/openOutpaint-webUI-extension/app/res/icons/eye.svg
new file mode 100644
index 0000000000000000000000000000000000000000..36329e0e01b4d00122d4d6b01d7b856c666edc63
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/eye.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/file-plus.svg b/openOutpaint-webUI-extension/app/res/icons/file-plus.svg
new file mode 100644
index 0000000000000000000000000000000000000000..1611710eaf8a55f60c4658f33e17fdeecdfc5d18
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/file-plus.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/file-up.svg b/openOutpaint-webUI-extension/app/res/icons/file-up.svg
new file mode 100644
index 0000000000000000000000000000000000000000..5bba64dc5057065d1b55d4e93f2719b0dee92933
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/file-up.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/file-x.svg b/openOutpaint-webUI-extension/app/res/icons/file-x.svg
new file mode 100644
index 0000000000000000000000000000000000000000..f2339afb3cd90110dae20ccde6290977912e656d
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/file-x.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/fish.svg b/openOutpaint-webUI-extension/app/res/icons/fish.svg
new file mode 100644
index 0000000000000000000000000000000000000000..c5f5b7212b1a3a4df7d1f05f0938aa7a68359c40
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/fish.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/flip-horizontal.svg b/openOutpaint-webUI-extension/app/res/icons/flip-horizontal.svg
new file mode 100644
index 0000000000000000000000000000000000000000..5248d1c1327c5b553ad1000a43489afbf888dbe0
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/flip-horizontal.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/flip-vertical.svg b/openOutpaint-webUI-extension/app/res/icons/flip-vertical.svg
new file mode 100644
index 0000000000000000000000000000000000000000..07416f77e81de38ff54db0da613e40fdacdefc04
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/flip-vertical.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/grid.svg b/openOutpaint-webUI-extension/app/res/icons/grid.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a7daf6d822d3c6b43ed103f2bacef997af41c392
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/grid.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/history.svg b/openOutpaint-webUI-extension/app/res/icons/history.svg
new file mode 100644
index 0000000000000000000000000000000000000000..c55b1cdbc4efc2c16428cee228f08c54023e2405
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/history.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/image-minus.svg b/openOutpaint-webUI-extension/app/res/icons/image-minus.svg
new file mode 100644
index 0000000000000000000000000000000000000000..51eafa0ae3a78f413d58fe3f644dfc3cced7a437
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/image-minus.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/image-plus.svg b/openOutpaint-webUI-extension/app/res/icons/image-plus.svg
new file mode 100644
index 0000000000000000000000000000000000000000..4a15bad301509313087a92a712e67137914718c0
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/image-plus.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/image.svg b/openOutpaint-webUI-extension/app/res/icons/image.svg
new file mode 100644
index 0000000000000000000000000000000000000000..a45c6a30e2d6f348a52ee2941f2a350f18ad3e46
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/image.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/joystick.svg b/openOutpaint-webUI-extension/app/res/icons/joystick.svg
new file mode 100644
index 0000000000000000000000000000000000000000..1b5e569a752c303c0a18be2303da5081f4bb4d60
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/joystick.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/library.svg b/openOutpaint-webUI-extension/app/res/icons/library.svg
new file mode 100644
index 0000000000000000000000000000000000000000..8a6f406408c60cae88f94289c7bbb9b36b3b1ec2
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/library.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/lock.svg b/openOutpaint-webUI-extension/app/res/icons/lock.svg
new file mode 100644
index 0000000000000000000000000000000000000000..ab991c1a4fe1be799cb8d1ec4f29e23730b7cd3a
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/lock.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/maximize.svg b/openOutpaint-webUI-extension/app/res/icons/maximize.svg
new file mode 100644
index 0000000000000000000000000000000000000000..5c8a6e39b066695123ba6459287e20840bcc3745
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/maximize.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/microscope.svg b/openOutpaint-webUI-extension/app/res/icons/microscope.svg
new file mode 100644
index 0000000000000000000000000000000000000000..11334dea77b63432237afaf8fa893751fb37c376
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/microscope.svg
@@ -0,0 +1,9 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/minus-square.svg b/openOutpaint-webUI-extension/app/res/icons/minus-square.svg
new file mode 100644
index 0000000000000000000000000000000000000000..ab8e9de818d79676feb633c9e6641fa1f4b2bd98
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/minus-square.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/minus.svg b/openOutpaint-webUI-extension/app/res/icons/minus.svg
new file mode 100644
index 0000000000000000000000000000000000000000..f840e9d6b28a4e06c4bb3e603fd55faa0ae0abc2
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/minus.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/more-horizontal.svg b/openOutpaint-webUI-extension/app/res/icons/more-horizontal.svg
new file mode 100644
index 0000000000000000000000000000000000000000..19deb1a3dd8d8808c1e0b3bf71e6087fa01ddd63
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/more-horizontal.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/paintbrush.svg b/openOutpaint-webUI-extension/app/res/icons/paintbrush.svg
new file mode 100644
index 0000000000000000000000000000000000000000..7b33711251bc32098cc07d9551173df82ae76dc8
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/paintbrush.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/pencil.svg b/openOutpaint-webUI-extension/app/res/icons/pencil.svg
new file mode 100644
index 0000000000000000000000000000000000000000..88a4365fa8db88fa6b20980fd1016dde641f97da
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/pencil.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/pin.svg b/openOutpaint-webUI-extension/app/res/icons/pin.svg
new file mode 100644
index 0000000000000000000000000000000000000000..aa858be4e9a82ae35480f8d214a90ec8cd1e53a5
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/pin.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/pipette.svg b/openOutpaint-webUI-extension/app/res/icons/pipette.svg
new file mode 100644
index 0000000000000000000000000000000000000000..89d058a483134c87fe5df54cdcdbe4529b03e482
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/pipette.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/plus-square.svg b/openOutpaint-webUI-extension/app/res/icons/plus-square.svg
new file mode 100644
index 0000000000000000000000000000000000000000..fa33a456542528d3cf07e83d08076cf22b52d061
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/plus-square.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/plus.svg b/openOutpaint-webUI-extension/app/res/icons/plus.svg
new file mode 100644
index 0000000000000000000000000000000000000000..d89208950eac629314bc3803aa16b0df0332ffd3
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/plus.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/refresh-cw.svg b/openOutpaint-webUI-extension/app/res/icons/refresh-cw.svg
new file mode 100644
index 0000000000000000000000000000000000000000..3a6fcaa3d6a8abec717026db2b7e23898e08cb74
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/refresh-cw.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/save.svg b/openOutpaint-webUI-extension/app/res/icons/save.svg
new file mode 100644
index 0000000000000000000000000000000000000000..d192fa45ab878079bc5b1deaaf6dfdd6d1d4ed5a
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/save.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/scaling.svg b/openOutpaint-webUI-extension/app/res/icons/scaling.svg
new file mode 100644
index 0000000000000000000000000000000000000000..e9b7d889e585c2bc772ad9e7f467f76399b49397
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/scaling.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/scissors.svg b/openOutpaint-webUI-extension/app/res/icons/scissors.svg
new file mode 100644
index 0000000000000000000000000000000000000000..7c3526af1fb60e11ff68c80e5db70660a0b58e3c
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/scissors.svg
@@ -0,0 +1,8 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/scroll.svg b/openOutpaint-webUI-extension/app/res/icons/scroll.svg
new file mode 100644
index 0000000000000000000000000000000000000000..6518100243da922b4fba2664250a985883547996
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/scroll.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/settings.svg b/openOutpaint-webUI-extension/app/res/icons/settings.svg
new file mode 100644
index 0000000000000000000000000000000000000000..88b47976702a66dbd16f1bd425ecd062a7cbd314
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/settings.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/slice.svg b/openOutpaint-webUI-extension/app/res/icons/slice.svg
new file mode 100644
index 0000000000000000000000000000000000000000..77284dc73bd25fd87c86cad1b8c2353cdb819f0d
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/slice.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/square.svg b/openOutpaint-webUI-extension/app/res/icons/square.svg
new file mode 100644
index 0000000000000000000000000000000000000000..ba8d0950121e5a17c63a8299fba7f146491666f4
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/square.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/trash.svg b/openOutpaint-webUI-extension/app/res/icons/trash.svg
new file mode 100644
index 0000000000000000000000000000000000000000..4ce815a7385e1be33dc01b39ca1366ae6184d706
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/trash.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/upload.svg b/openOutpaint-webUI-extension/app/res/icons/upload.svg
new file mode 100644
index 0000000000000000000000000000000000000000..83c4885cc94b51f3e067e9508fc741021db363e0
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/upload.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/venetian-mask.svg b/openOutpaint-webUI-extension/app/res/icons/venetian-mask.svg
new file mode 100644
index 0000000000000000000000000000000000000000..6cd896236ca093bedb32bc8e38911e493e1d8efd
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/venetian-mask.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/res/icons/x.svg b/openOutpaint-webUI-extension/app/res/icons/x.svg
new file mode 100644
index 0000000000000000000000000000000000000000..95d4bc1c349434f11dff2d2d8802dcab023efe87
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/res/icons/x.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/app/userdefinedscripts.json b/openOutpaint-webUI-extension/app/userdefinedscripts.json
new file mode 100644
index 0000000000000000000000000000000000000000..544c1a4b24cba8ac604f3ac3ab1d54dd374c140c
--- /dev/null
+++ b/openOutpaint-webUI-extension/app/userdefinedscripts.json
@@ -0,0 +1,8 @@
+{
+ "userScripts": {
+ "testScript1": {
+ "title": "template",
+ "scriptValues": "[1, 0.999, true, \"4-10 [3]\", \"test text\"]"
+ }
+ }
+}
diff --git a/openOutpaint-webUI-extension/install.py b/openOutpaint-webUI-extension/install.py
new file mode 100644
index 0000000000000000000000000000000000000000..ee5b27291e9a0b2673146068629bf11f72059ba7
--- /dev/null
+++ b/openOutpaint-webUI-extension/install.py
@@ -0,0 +1,21 @@
+import sys
+import os
+from modules import scripts
+
+git = os.environ.get('GIT', "git")
+usefulDirs = sys.argv[0].split(os.sep)[-3:]
+
+installDir = os.path.join(scripts.basedir(), usefulDirs[0], usefulDirs[1])
+
+# Attempt to use launch module from webui
+command = f'"{git}" -C "' + installDir +\
+ '" submodule update --init --recursive --remote'
+if not os.path.isfile(os.path.join(installDir, "app", "index.html")):
+ try:
+ from launch import run
+ stdout = run(command)
+ if stdout is not None:
+ print(stdout)
+ except ImportError:
+ print("[openoutpaint-extension] We failed to import the 'launch' module. Using 'os'")
+ os.system(command)
diff --git a/openOutpaint-webUI-extension/javascript/openoutpaint-ext.js b/openOutpaint-webUI-extension/javascript/openoutpaint-ext.js
new file mode 100644
index 0000000000000000000000000000000000000000..a1e7dc73caed5f4559f2e869254e7e91b0b32063
--- /dev/null
+++ b/openOutpaint-webUI-extension/javascript/openoutpaint-ext.js
@@ -0,0 +1,325 @@
+// Txt2Img Send to Resource
+const openoutpaint = {
+ frame: null,
+ key: null,
+};
+
+/**
+ * Converts a Data URL string to a file object
+ *
+ * Based on https://stackoverflow.com/questions/28041840/convert-dataurl-to-file-using-javascript
+ *
+ * @param {string} dataurl Data URL to load into a file
+ * @returns
+ */
+function openoutpaint_dataURLtoFile(dataurl) {
+ var arr = dataurl.split(","),
+ mime = arr[0].match(/:(.*?);/)[1],
+ bstr = atob(arr[1]),
+ n = bstr.length,
+ u8arr = new Uint8Array(n);
+ while (n--) {
+ u8arr[n] = bstr.charCodeAt(n);
+ }
+ return new File([u8arr], "openOutpaint-file", {type: mime});
+}
+
+async function openoutpaint_get_image_from_gallery() {
+ var buttons = gradioApp().querySelectorAll(
+ '[style="display: block;"].tabitem div[id$=_gallery] .thumbnail-item.thumbnail-small'
+ );
+ var button = gradioApp().querySelector(
+ '[style="display: block;"].tabitem div[id$=_gallery] .thumbnail-item.thumbnail-small.selected'
+ );
+
+ if (!button) button = buttons[0];
+
+ if (!button)
+ throw new Error("[openoutpaint] No image available in the gallery");
+
+ const canvas = document.createElement("canvas");
+ const image = document.createElement("img");
+ image.src = button.querySelector("img").src;
+
+ await image.decode();
+
+ canvas.width = image.width;
+ canvas.height = image.height;
+
+ canvas.getContext("2d").drawImage(image, 0, 0);
+
+ return canvas.toDataURL();
+}
+
+function openoutpaint_send_image(dataURL, name = "Embed Resource") {
+ openoutpaint.frame.contentWindow.postMessage({
+ key: openoutpaint.key,
+ type: "openoutpaint/add-resource",
+ image: {
+ dataURL,
+ resourceName: name,
+ },
+ });
+}
+
+function openoutpaint_gototab(tabname = "openOutpaint", tabsId = "tabs") {
+ Array.from(
+ gradioApp().querySelectorAll(`#${tabsId} > div:first-child button`)
+ ).forEach((button) => {
+ if (button.textContent.trim() === tabname) {
+ button.click();
+ }
+ });
+}
+
+function openoutpaint_send_gallery(name = "Embed Resource") {
+ openoutpaint_get_image_from_gallery()
+ .then((dataURL) => {
+ // Send to openOutpaint
+ openoutpaint_send_image(dataURL, name);
+
+ // Send prompt to openOutpaint
+ const tab = get_uiCurrentTabContent().id;
+
+ if (["tab_txt2img", "tab_img2img"].includes(tab)) {
+ const prompt =
+ tab === "tab_txt2img"
+ ? gradioApp().querySelector("#txt2img_prompt textarea").value
+ : gradioApp().querySelector("#img2img_prompt textarea").value;
+ const negPrompt =
+ tab === "tab_txt2img"
+ ? gradioApp().querySelector("#txt2img_neg_prompt textarea").value
+ : gradioApp().querySelector("#img2img_neg_prompt textarea").value;
+ openoutpaint.frame.contentWindow.postMessage({
+ key: openoutpaint.key,
+ type: "openoutpaint/set-prompt",
+ prompt,
+ negPrompt,
+ });
+ }
+
+ // Change Tab
+ openoutpaint_gototab();
+ })
+ .catch((error) => {
+ console.warn("[openoutpaint] No image selected to send to openOutpaint");
+ });
+}
+
+const openoutpaintjs = async () => {
+ const frame = gradioApp().getElementById("openoutpaint-iframe");
+ const key = gradioApp().getElementById("openoutpaint-key").value;
+
+ openoutpaint.frame = frame;
+ openoutpaint.key = key;
+
+ // Listens for messages from the frame
+ console.info("[openoutpaint] Add message listener");
+ window.addEventListener("message", ({data, origin, source}) => {
+ if (source === frame.contentWindow) {
+ switch (data.type) {
+ case "openoutpaint/ack":
+ if (data.message.type === "openoutpaint/init") {
+ console.info("[openoutpaint] Received Init Ack");
+ clearTimeout(initLoop);
+ initLoop = null;
+ }
+ break;
+ case "openoutpaint/sendto":
+ console.info(
+ `[openoutpaint] Exported image to '${data.message.destination}'`
+ );
+ const container = new DataTransfer();
+ const file = openoutpaint_dataURLtoFile(data.message.image);
+ container.items.add(file);
+
+ const setImageInput = (selector) => {
+ const inputel = gradioApp().querySelector(selector);
+ inputel.files = container.files;
+ inputel.dispatchEvent(new Event("change"));
+ };
+
+ switch (data.message.destination) {
+ case "img2img":
+ openoutpaint_gototab("img2img");
+ openoutpaint_gototab("img2img", "mode_img2img");
+ setImageInput("#img2img_img2img_tab input[type=file]");
+ break;
+ case "img2img_sketch":
+ openoutpaint_gototab("img2img");
+ openoutpaint_gototab("Sketch", "mode_img2img");
+ setImageInput("#img2img_img2img_sketch_tab input[type=file]");
+ break;
+ case "img2img_inpaint":
+ openoutpaint_gototab("img2img");
+ openoutpaint_gototab("Inpaint", "mode_img2img");
+ setImageInput("#img2img_inpaint_tab input[type=file]");
+ break;
+ case "img2img_sketch_inpaint":
+ openoutpaint_gototab("img2img");
+ openoutpaint_gototab("Inpaint sketch", "mode_img2img");
+ setImageInput("#img2img_inpaint_sketch_tab input[type=file]");
+ break;
+ case "extras":
+ openoutpaint_gototab("Extras");
+ setImageInput("#extras_single_tab input[type=file]");
+ break;
+ case "pnginfo":
+ openoutpaint_gototab("PNG Info");
+ setImageInput("#tab_pnginfo input[type=file]");
+ break;
+ default:
+ console.warn(
+ `[openoutpaint] Unknown destination ${data.message.destination}`
+ );
+ }
+ break;
+ }
+ }
+ });
+
+ // Initializes communication channel
+ let initLoop = null;
+ const sendInit = () => {
+ console.info("[openoutpaint] Sending init message");
+ const pathname = window.location.pathname;
+ const host = `${window.location.origin}${
+ pathname.endsWith("/")
+ ? pathname.substring(0, pathname.length - 1)
+ : pathname
+ }`;
+ frame.contentWindow.postMessage({
+ type: "openoutpaint/init",
+ key,
+ host,
+ destinations: [
+ {
+ name: "Image to Image",
+ id: "img2img",
+ },
+ {
+ name: "Sketch",
+ id: "img2img_sketch",
+ },
+ {
+ name: "Inpaint",
+ id: "img2img_inpaint",
+ },
+ {
+ name: "Sketch & Inpaint",
+ id: "img2img_sketch_inpaint",
+ },
+ {
+ name: "Extras",
+ id: "extras",
+ },
+ {
+ name: "PNG Info",
+ id: "pnginfo",
+ },
+ ],
+ });
+ initLoop = setTimeout(sendInit, 1000);
+ };
+
+ frame.addEventListener("load", () => {
+ sendInit();
+ });
+
+ // Setup openOutpaint tab scaling
+ const tabEl = gradioApp().getElementById("tab_openOutpaint");
+ frame.style.left = "0px";
+
+ const refreshBtn = document.createElement("button");
+ refreshBtn.id = "openoutpaint-refresh";
+ refreshBtn.textContent = "🔄";
+ refreshBtn.title = "Refresh openOutpaint";
+ refreshBtn.style.width = "fit-content";
+ refreshBtn.classList.add("gr-button", "gr-button-lg", "gr-button-secondary");
+ refreshBtn.addEventListener("click", async () => {
+ if (confirm("Are you sure you want to refresh openOutpaint?")) {
+ frame.contentWindow.location.reload();
+ }
+ });
+ tabEl.appendChild(refreshBtn);
+
+ const recalculate = () => {
+ // If we are on the openoutpaint tab, recalculate
+ if (tabEl.style.display !== "none") {
+ frame.style.height = window.innerHeight + "px";
+ const current = document.body.scrollHeight;
+ const bb = frame.getBoundingClientRect();
+ const iframeh = bb.height;
+ const innerh = window.innerHeight;
+ frame.style.height = `${Math.floor(iframeh + (innerh - current)) - 1}px`;
+ frame.style.width = `${Math.floor(window.innerWidth) - 1}px`;
+ frame.style.left = `${Math.floor(
+ parseInt(frame.style.left, 10) - bb.x
+ )}px`;
+ }
+ };
+
+ window.addEventListener("resize", () => {
+ recalculate();
+ });
+
+ new MutationObserver((e) => {
+ recalculate();
+ }).observe(tabEl, {
+ attributes: true,
+ });
+
+ // Add button to other tabs
+ const createButton = () => {
+ const button = document.createElement("button");
+ button.id = "openOutpaint_tab";
+ button.classList.add("lg", "secondary", "gradio-button", "svelte-1ipelgc");
+ button.textContent = "Send to openOutpaint";
+ return button;
+ };
+
+ const extrasBtn = createButton();
+ extrasBtn.addEventListener("click", () =>
+ openoutpaint_send_gallery("WebUI Extras Resource")
+ );
+ gradioApp().querySelector("#tab_extras button#extras_tab").after(extrasBtn);
+
+ const pnginfoBtn = createButton();
+ pnginfoBtn.addEventListener("click", () => {
+ const image = gradioApp().querySelector("#pnginfo_image img");
+ if (image && image.src) {
+ openoutpaint_send_image(image.src, "WebUI PNGInfo Resource");
+ openoutpaint_gototab();
+ }
+ });
+ gradioApp().querySelector("#tab_pnginfo button#extras_tab").after(pnginfoBtn);
+
+ // Initial calculations
+ sendInit();
+ recalculate();
+
+ new MutationObserver((mutations) => {
+ if (
+ mutations.some(
+ (mutation) =>
+ mutation.attributeName === "style" &&
+ mutation.target.style.display !== "none"
+ )
+ )
+ frame.contentWindow.focus();
+ }).observe(tabEl, {
+ attributes: true,
+ });
+
+ if (tabEl.style.display !== "none") frame.contentWindow.focus();
+};
+document.addEventListener("DOMContentLoaded", () => {
+ const onload = () => {
+ if (gradioApp().getElementById("openoutpaint-iframe")) {
+ openoutpaintjs();
+ } else {
+ setTimeout(onload, 10);
+ }
+ };
+ onload();
+});
diff --git a/openOutpaint-webUI-extension/javascript/openoutpaint-imagehistory.js b/openOutpaint-webUI-extension/javascript/openoutpaint-imagehistory.js
new file mode 100644
index 0000000000000000000000000000000000000000..c5be5911d30a0eaf2582962fcbc3b5499c2bac7f
--- /dev/null
+++ b/openOutpaint-webUI-extension/javascript/openoutpaint-imagehistory.js
@@ -0,0 +1,115 @@
+async function openoutpaint_get_image_from_history() {
+ return new Promise(function (resolve, reject) {
+ var buttons = gradioApp().querySelectorAll(
+ '#tab_images_history [style="display: block;"].tabitem div[id$=_gallery] .gallery-item'
+ );
+ var button = gradioApp().querySelector(
+ '#tab_images_history [style="display: block;"].tabitem div[id$=_gallery] .gallery-item.\\!ring-2'
+ );
+
+ if (!button) button = buttons[0];
+
+ if (!button)
+ reject(new Error("[openoutpaint] No image available in the gallery"));
+
+ const canvas = document.createElement("canvas");
+ const image = document.createElement("img");
+ image.onload = () => {
+ canvas.width = image.width;
+ canvas.height = image.height;
+
+ canvas.getContext("2d").drawImage(image, 0, 0);
+
+ resolve(canvas.toDataURL());
+ };
+ image.src = button.querySelector("img").src;
+ });
+}
+
+function openoutpaint_send_history_gallery(
+ name = "Image Browser Resource",
+ tab
+) {
+ openoutpaint_get_image_from_history()
+ .then((dataURL) => {
+ // Send to openOutpaint
+ openoutpaint.frame.contentWindow.postMessage({
+ key: openoutpaint.key,
+ type: "openoutpaint/add-resource",
+ image: {
+ dataURL,
+ resourceName: name,
+ },
+ });
+
+ // Send prompt to openOutpaint
+ const tab = get_uiCurrentTabContent().id;
+ const prompt =
+ tab === "tab_txt2img"
+ ? gradioApp().querySelector("#txt2img_prompt textarea").value
+ : gradioApp().querySelector("#img2img_prompt textarea").value;
+ const negPrompt =
+ tab === "tab_txt2img"
+ ? gradioApp().querySelector("#txt2img_neg_prompt textarea").value
+ : gradioApp().querySelector("#img2img_neg_prompt textarea").value;
+ openoutpaint.frame.contentWindow.postMessage({
+ key: openoutpaint.key,
+ type: "openoutpaint/set-prompt",
+ prompt,
+ negPrompt,
+ });
+
+ // Change Tab
+ Array.from(
+ gradioApp().querySelectorAll("#tabs > div:first-child button")
+ ).forEach((button) => {
+ if (button.textContent.trim() === "openOutpaint") {
+ button.click();
+ }
+ });
+ })
+ .catch((error) => {
+ console.warn("[openoutpaint] No image selected to send to openOutpaint");
+ });
+}
+
+document.addEventListener("DOMContentLoaded", () => {
+ let tries = 3;
+ const onload = () => {
+ const element = gradioApp().getElementById("tab_images_history");
+ const tabsEl = gradioApp().getElementById("images_history_tab");
+ if (element) {
+ console.debug(`[oo-ext] Detected image history extension`);
+
+ // Gets tab buttons
+ const tabs = Array.from(tabsEl.firstChild.querySelectorAll("button")).map(
+ (button) => button.textContent.trim()
+ );
+
+ tabs.forEach((tab) => {
+ const buttonPanel = gradioApp().getElementById(
+ `${tab}_images_history_button_panel`
+ );
+
+ if (!buttonPanel) return;
+
+ const button = document.createElement("button");
+ button.textContent = "Send to openOutpaint";
+ button.classList.add(
+ "gr-button",
+ "gr-button-lg",
+ "gr-button-secondary"
+ );
+ button.addEventListener("click", () => {
+ openoutpaint_send_history_gallery(`Image Browser (${tab}) Resource`);
+ });
+
+ buttonPanel.appendChild(button);
+ });
+ } else if (tries-- > 0) {
+ // Tries n times every 1 second before giving up
+ setTimeout(onload, 1000);
+ }
+ };
+ onload();
+});
diff --git a/openOutpaint-webUI-extension/preload.py b/openOutpaint-webUI-extension/preload.py
new file mode 100644
index 0000000000000000000000000000000000000000..00a0a130861cbb0b4a123db9b4015edd3e6ee665
--- /dev/null
+++ b/openOutpaint-webUI-extension/preload.py
@@ -0,0 +1,6 @@
+import argparse
+
+def preload(parser: argparse.ArgumentParser):
+ parser.add_argument("--lock-oo-submodule", action='store_true',
+ help="(openOutpaint-webUI-extension) Prevent checking for main openOutpaint submodule updates.")
+
\ No newline at end of file
diff --git a/openOutpaint-webUI-extension/scripts/api.py b/openOutpaint-webUI-extension/scripts/api.py
new file mode 100644
index 0000000000000000000000000000000000000000..1660a1e7c71be211cbf68aec3ef30b09733d9438
--- /dev/null
+++ b/openOutpaint-webUI-extension/scripts/api.py
@@ -0,0 +1,64 @@
+from fastapi import FastAPI, Response, Form
+from fastapi.responses import JSONResponse
+from modules import sd_models, shared, scripts
+import asyncio
+import gradio as gr
+
+
+def test_api(_: gr.Blocks, app: FastAPI):
+ """
+ it kept yelling at me without the stupid gradio import there, i'm sure i did something wrong
+ --------------------------
+ Error executing callback app_started_callback for E:\storage\stable-diffusion-webui\extensions\apitest\scripts\api.py
+ Traceback (most recent call last):
+ File "E:\storage\stable-diffusion-webui\modules\script_callbacks.py", line 88, in app_started_callback
+ c.callback(demo, app)
+ TypeError: test_api() takes 1 positional argument but 2 were given
+ """
+ @app.post("/openOutpaint/unet-count")
+ async def return_model_unet_channel_count(
+ model_name: str = Form(description="the model to be inspected")
+ ):
+ err_msg = ""
+ try:
+ model = sd_models.checkpoints_list[model_name]
+ except:
+ err_msg = "submitted model failed loading, falling back to loaded model"
+ model = sd_models.checkpoints_list[get_current_model()]
+ theta_0 = sd_models.read_state_dict(model.filename, map_location='cpu')
+ channelCount = theta_0["model.diffusion_model.input_blocks.0.0.weight"].shape[1]
+ return {
+ "unet_channels": channelCount,
+ "estimated_type": switchAssumption(channelCount),
+ "tested_model": model,
+ "additional_data": err_msg
+ }
+
+def switchAssumption(channelCount):
+ return {
+ 4: "traditional",
+ 5: "sdv2 depth2img",
+ 7: "sdv2 upscale 4x",
+ 8: "instruct-pix2pix",
+ 9: "inpainting"
+ }.get(channelCount, "¯\_(ツ)_/¯")
+
+def get_current_model():
+ options = {}
+ for key in shared.opts.data.keys():
+ metadata = shared.opts.data_labels.get(key)
+ if(metadata is not None):
+ options.update({key: shared.opts.data.get(key, shared.opts.data_labels.get(key).default)})
+ else:
+ options.update({key: shared.opts.data.get(key, None)})
+
+ return options["sd_model_checkpoint"] # super inefficient but i'm a moron
+
+
+try:
+ import modules.script_callbacks as script_callbacks
+ script_callbacks.on_app_started(test_api)
+except:
+ print("[openOutpaint-webui-extension] UNET API failed to initialize")
+
+
diff --git a/openOutpaint-webUI-extension/scripts/interface.py b/openOutpaint-webUI-extension/scripts/interface.py
new file mode 100644
index 0000000000000000000000000000000000000000..044a764a2da8d112ee27c76ac86d53e0b58c4cab
--- /dev/null
+++ b/openOutpaint-webUI-extension/scripts/interface.py
@@ -0,0 +1,21 @@
+import gradio as gr
+from modules import scripts
+
+
+class Script(scripts.Script):
+ def title(self):
+ return "OpenOutpaint"
+
+ def show(self, is_img2img):
+ return scripts.AlwaysVisible
+
+ def after_component(self, component, **kwargs):
+ # Add button to both txt2img and img2img tabs
+ if kwargs.get("elem_id") == "extras_tab":
+ basic_send_button = gr.Button(
+ "Send to openOutpaint", elem_id=f"openoutpaint_button")
+ basic_send_button.click(None, [], None,
+ _js="() => openoutpaint_send_gallery('WebUI Resource')")
+
+ def ui(self, is_img2img):
+ return []
diff --git a/openOutpaint-webUI-extension/scripts/main.py b/openOutpaint-webUI-extension/scripts/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..7ed2d38acfd4652c9791dd3e9664a263fe090c5f
--- /dev/null
+++ b/openOutpaint-webUI-extension/scripts/main.py
@@ -0,0 +1,87 @@
+from modules import script_callbacks, scripts, shared
+import gradio as gr
+from fastapi import FastAPI
+import os
+from launch import run
+import pathlib
+import inspect
+
+import string
+import random as rd
+
+
+extension_dir = pathlib.Path(inspect.getfile(lambda: None)).parent.parent
+key_characters = (string.ascii_letters + string.digits)
+
+
+def random_string(length=20):
+ return ''.join([rd.choice(key_characters) for _ in range(length)])
+
+
+key = random_string()
+
+
+def get_files(path):
+ # Gets all files
+ directories = set()
+ for root, _, files in os.walk(path.resolve()):
+ for file in files:
+ directories.add(root + '/' + file)
+
+ return directories
+
+
+def started(demo, app: FastAPI):
+ try:
+ # Force allow paths for fixing symlinked extension directory references
+ force_allow = get_files(extension_dir / "app")
+
+ # Add to allowed files list
+ app.blocks.temp_file_sets.append(force_allow)
+
+ # Force allow paths for fixing symlinked extension directory references (base javascript files now)
+ force_allow = get_files(extension_dir / "javascript")
+
+ # Add to allowed files list
+ app.blocks.temp_file_sets.append(force_allow)
+ except Exception:
+ print(f"[openOutpaint] Could not force allowed files. Skipping...")
+ pass
+
+
+def update_app():
+ git = os.environ.get('GIT', "git")
+ # print(scripts.basedir)
+ run(f'"{git}" -C "' + os.path.join(scripts.basedir(), usefulDirs[0], usefulDirs[1]) +
+ '" submodule update --init --recursive --remote', live=True)
+
+
+def add_tab():
+ try:
+ if shared.cmd_opts.lock_oo_submodule:
+ print(f"[openOutpaint] Submodule locked. Will skip submodule update.")
+ else:
+ update_app()
+ except Exception:
+ update_app()
+
+ with gr.Blocks(analytics_enabled=False) as ui:
+ #refresh = gr.Button(value="refresh", variant="primary")
+ canvas = gr.HTML(
+ f"")
+ keyinput = gr.HTML(
+ f"")
+
+ return [(ui, "openOutpaint", "openOutpaint")]
+
+
+usefulDirs = scripts.basedir().split(os.sep)[-2:]
+
+with open(f"{scripts.basedir()}/app/key.json", "w") as keyfile:
+ keyfile.write('{\n')
+ keyfile.write(f" \"key\": \"{key}\"\n")
+ keyfile.write('}\n')
+ keyfile.close()
+
+script_callbacks.on_ui_tabs(add_tab)
+script_callbacks.on_app_started(started)
diff --git a/openOutpaint-webUI-extension/style.css b/openOutpaint-webUI-extension/style.css
new file mode 100644
index 0000000000000000000000000000000000000000..57ea92f8bf74bdacb84fc2d2d1b7d1e479438427
--- /dev/null
+++ b/openOutpaint-webUI-extension/style.css
@@ -0,0 +1,28 @@
+#tab_openOutpaint {
+ position: relative;
+
+ padding: 0;
+ border: 0;
+}
+
+#openoutpaint-iframe {
+ position: absolute;
+
+ top: -2px;
+
+ box-sizing: border-box;
+
+ z-index: 1;
+}
+
+#openoutpaint-refresh {
+ position: absolute;
+
+ top: 10px;
+ left: 0;
+ right: 0;
+
+ margin: auto;
+
+ z-index: 9999; /* little guy gets bashful and hides behind the canvas in firefox */
+}
diff --git a/openpose-editor/LICENSE b/openpose-editor/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..5bbefdcf7b96b8e370b7e405c6e93360f69eb250
--- /dev/null
+++ b/openpose-editor/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Fkunn1326
+
+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.
diff --git a/openpose-editor/README.en.md b/openpose-editor/README.en.md
new file mode 100644
index 0000000000000000000000000000000000000000..e3f38af7411e224bd0242c81e066d2d213e51191
--- /dev/null
+++ b/openpose-editor/README.en.md
@@ -0,0 +1,40 @@
+## Openpose Editor
+
+[日本語](README.md) | English | [简体中文](README.zh-cn.md)
+
+
+
+Openpose Editor for Automatic1111/stable-diffusion-webui
+
+- Pose editing
+- Pose detection
+
+This can:
+
+- Add a new person
+- Detect pose from an image
+- Add background image
+
+- Save as a PNG
+- Send to ControlNet extension
+
+## Installation
+
+1. Open the "Extension" tab
+2. Click on "Install from URL"
+3. In "URL for extension's git repository" enter this extension, https://github.com/fkunn1326/openpose-editor.git
+4. Click "Install"
+5. Restart WebUI
+
+## Attention
+
+Do not select anything for the Preprocessor in ControlNet.
+
+
+## Fix Error
+> urllib.error.URLError:
+
+Run
+```
+/Applications/Python\ $version /Install\ Certificates.command
+```
diff --git a/openpose-editor/README.md b/openpose-editor/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..b743b8899413d5702c55b2331a8ac86a25be68c4
--- /dev/null
+++ b/openpose-editor/README.md
@@ -0,0 +1,41 @@
+## Openpose Editor
+
+日本語 | [English](README.en.md) | [简体中文](README.zh-cn.md)
+
+
+
+Automatic1111/stable-diffusion-webui用のOpenpose Editor
+
+- ポーズの編集
+- ポーズの検出
+
+ができます
+
+- 「Add」: 人を追加する
+- 「Detect from image」: 画像からポーズを検出する
+- 「Add Background image」: 背景を追加する
+
+- 「Save PNG」: PNGで保存する
+- 「Send to ControlNet」: Controlnet拡張機能がインストールされている場合、画像をそこに送る
+
+## インストール方法
+
+1. "Extension" タブを開く
+2. "Install from URL" タブを開く
+3. "URL for extension's git repository" 欄にこのリポジトリの URL (https://github.com/fkunn1326/openpose-editor.git) を入れます。
+4. "Install" ボタンを押す
+5. WebUIを再起動する
+
+## 注意
+
+ControlNetの "Preprocessor" には、何も指定しないようにしてください。
+
+## エラーの対策
+
+> urllib.error.URLError:
+
+
+以下のファイルを開いてださい
+```
+/Applications/Python\ $version /Install\ Certificates.command
+```
diff --git a/openpose-editor/README.zh-cn.md b/openpose-editor/README.zh-cn.md
new file mode 100644
index 0000000000000000000000000000000000000000..1a3bae30a3e4e01c95d3ab52660b82e08d8513eb
--- /dev/null
+++ b/openpose-editor/README.zh-cn.md
@@ -0,0 +1,41 @@
+## Openpose Editor
+
+[日本語](README.md) | [English](README.en.md)|中文
+
+
+
+适用于Automatic1111/stable-diffusion-webui 的Openpose Editor 插件。
+
+功能:
+- 直接编辑骨骼动作
+- 从图像识别姿势
+
+本插件实现以下操作:
+
+- 「Add」:添加一个新骨骼
+- 「Detect from image」: 从图片中识别姿势
+- 「Add Background image」: 添加背景图片
+- 「Load JSON」:载入JSON文件
+
+- 「Save PNG」: 保存为PNG格式图片
+- 「Send to ControlNet」:将骨骼姿势发送到 ControlNet
+- 「Save JSON」:将骨骼保存为JSON
+## 安装方法
+
+1. 打开扩展(Extension)标签。
+2. 点击从网址安装(Install from URL)
+3. 在扩展的 git 仓库网址(URL for extension's git repository)处输入 https://github.com/fkunn1326/openpose-editor.git
+4. 点击安装(Install)
+5. 重启 WebUI
+## 注意
+
+不要给ConrtolNet 的 "Preprocessor" 选项指定任何值,请保持在none状态
+
+## 常见问题
+Mac OS可能会出现:
+> urllib.error.URLError:
+
+请执行文件
+```
+/Applications/Python\ $version /Install\ Certificates.command
+```
diff --git a/openpose-editor/_typos.toml b/openpose-editor/_typos.toml
new file mode 100644
index 0000000000000000000000000000000000000000..12a9310f8a76872449071de5072e507cc9054905
--- /dev/null
+++ b/openpose-editor/_typos.toml
@@ -0,0 +1,11 @@
+# Files for typos
+# Instruction: https://github.com/marketplace/actions/typos-action#getting-started
+
+[default.extend-identifiers]
+
+[default.extend-words]
+
+
+
+[files]
+extend-exclude = ["_typos.toml", "fabric.js"]
diff --git a/openpose-editor/configs/.gitkeep b/openpose-editor/configs/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git "a/openpose-editor/images/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210 2023-02-19 131430.png" "b/openpose-editor/images/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210 2023-02-19 131430.png"
new file mode 100644
index 0000000000000000000000000000000000000000..c8362eac20702e19fed560427cb0c558039d0319
Binary files /dev/null and "b/openpose-editor/images/\343\202\271\343\202\257\343\203\252\343\203\274\343\203\263\343\202\267\343\203\247\343\203\203\343\203\210 2023-02-19 131430.png" differ
diff --git a/openpose-editor/javascript/fabric.js b/openpose-editor/javascript/fabric.js
new file mode 100644
index 0000000000000000000000000000000000000000..f679affaaa05e24e84fae188d32c0079c11f7b90
--- /dev/null
+++ b/openpose-editor/javascript/fabric.js
@@ -0,0 +1 @@
+var fabric=fabric||{version:"5.3.0"};if("undefined"!=typeof exports?exports.fabric=fabric:"function"==typeof define&&define.amd&&define([],function(){return fabric}),"undefined"!=typeof document&&"undefined"!=typeof window)document instanceof("undefined"!=typeof HTMLDocument?HTMLDocument:Document)?fabric.document=document:fabric.document=document.implementation.createHTMLDocument(""),fabric.window=window;else{var jsdom=require("jsdom"),virtualWindow=new jsdom.JSDOM(decodeURIComponent("%3C!DOCTYPE%20html%3E%3Chtml%3E%3Chead%3E%3C%2Fhead%3E%3Cbody%3E%3C%2Fbody%3E%3C%2Fhtml%3E"),{features:{FetchExternalResources:["img"]},resources:"usable"}).window;fabric.document=virtualWindow.document,fabric.jsdomImplForWrapper=require("jsdom/lib/jsdom/living/generated/utils").implForWrapper,fabric.nodeCanvas=require("jsdom/lib/jsdom/utils").Canvas,fabric.window=virtualWindow,DOMParser=fabric.window.DOMParser}function resizeCanvasIfNeeded(t){var e=t.targetCanvas,i=e.width,r=e.height,n=t.destinationWidth,s=t.destinationHeight;i===n&&r===s||(e.width=n,e.height=s)}function copyGLTo2DDrawImage(t,e){var i=t.canvas,r=e.targetCanvas,n=r.getContext("2d");n.translate(0,r.height),n.scale(1,-1);var s=i.height-r.height;n.drawImage(i,0,s,r.width,r.height,0,0,r.width,r.height)}function copyGLTo2DPutImageData(t,e){var i=e.targetCanvas.getContext("2d"),r=e.destinationWidth,n=e.destinationHeight,s=r*n*4,o=new Uint8Array(this.imageBuffer,0,s),a=new Uint8ClampedArray(this.imageBuffer,0,s);t.readPixels(0,0,r,n,t.RGBA,t.UNSIGNED_BYTE,o);var c=new ImageData(a,r,n);i.putImageData(c,0,0)}fabric.isTouchSupported="ontouchstart"in fabric.window||"ontouchstart"in fabric.document||fabric.window&&fabric.window.navigator&&0_)for(var C=1,S=d.length;Ct[i-2].x?1:n.x===t[i-2].x?0:-1,c=n.y>t[i-2].y?1:n.y===t[i-2].y?0:-1),r.push(["L",n.x+a*e,n.y+c*e]),r},fabric.util.getPathSegmentsInfo=l,fabric.util.getBoundsOfCurve=function(t,e,i,r,n,s,o,a){var c;if(fabric.cachesBoundsOfCurve&&(c=A.call(arguments),fabric.boundsOfCurveCache[c]))return fabric.boundsOfCurveCache[c];var h,l,u,f,d,g,p,v,m=Math.sqrt,b=Math.min,y=Math.max,_=Math.abs,x=[],C=[[],[]];l=6*t-12*i+6*n,h=-3*t+9*i-9*n+3*o,u=3*i-3*t;for(var S=0;S<2;++S)if(0/g,">")},graphemeSplit:function(t){var e,i=0,r=[];for(i=0;it.x&&this.y>t.y},gte:function(t){return this.x>=t.x&&this.y>=t.y},lerp:function(t,e){return void 0===e&&(e=.5),e=Math.max(Math.min(1,e),0),new i(this.x+(t.x-this.x)*e,this.y+(t.y-this.y)*e)},distanceFrom:function(t){var e=this.x-t.x,i=this.y-t.y;return Math.sqrt(e*e+i*i)},midPointFrom:function(t){return this.lerp(t)},min:function(t){return new i(Math.min(this.x,t.x),Math.min(this.y,t.y))},max:function(t){return new i(Math.max(this.x,t.x),Math.max(this.y,t.y))},toString:function(){return this.x+","+this.y},setXY:function(t,e){return this.x=t,this.y=e,this},setX:function(t){return this.x=t,this},setY:function(t){return this.y=t,this},setFromPoint:function(t){return this.x=t.x,this.y=t.y,this},swap:function(t){var e=this.x,i=this.y;this.x=t.x,this.y=t.y,t.x=e,t.y=i},clone:function(){return new i(this.x,this.y)}}}("undefined"!=typeof exports?exports:this),function(t){"use strict";var f=t.fabric||(t.fabric={});function d(t){this.status=t,this.points=[]}f.Intersection?f.warn("fabric.Intersection is already defined"):(f.Intersection=d,f.Intersection.prototype={constructor:d,appendPoint:function(t){return this.points.push(t),this},appendPoints:function(t){return this.points=this.points.concat(t),this}},f.Intersection.intersectLineLine=function(t,e,i,r){var n,s=(r.x-i.x)*(t.y-i.y)-(r.y-i.y)*(t.x-i.x),o=(e.x-t.x)*(t.y-i.y)-(e.y-t.y)*(t.x-i.x),a=(r.y-i.y)*(e.x-t.x)-(r.x-i.x)*(e.y-t.y);if(0!==a){var c=s/a,h=o/a;0<=c&&c<=1&&0<=h&&h<=1?(n=new d("Intersection")).appendPoint(new f.Point(t.x+c*(e.x-t.x),t.y+c*(e.y-t.y))):n=new d}else n=new d(0===s||0===o?"Coincident":"Parallel");return n},f.Intersection.intersectLinePolygon=function(t,e,i){var r,n,s,o,a=new d,c=i.length;for(o=0;o=c&&(h.x-=c),h.x<=-c&&(h.x+=c),h.y>=c&&(h.y-=c),h.y<=c&&(h.y+=c),h.x-=o.offsetX,h.y-=o.offsetY,h}function y(t){return t.flipX!==t.flipY}function _(t,e,i,r,n){if(0!==t[e]){var s=n/t._getTransformedDimensions()[r]*t[i];t.set(i,s)}}function x(t,e,i,r){var n,s=e.target,o=s._getTransformedDimensions(0,s.skewY),a=P(e,e.originX,e.originY,i,r),c=Math.abs(2*a.x)-o.x,h=s.skewX;c<2?n=0:(n=v(Math.atan2(c/s.scaleX,o.y/s.scaleY)),e.originX===f&&e.originY===p&&(n=-n),e.originX===g&&e.originY===d&&(n=-n),y(s)&&(n=-n));var l=h!==n;if(l){var u=s._getTransformedDimensions().y;s.set("skewX",n),_(s,"skewY","scaleY","y",u)}return l}function C(t,e,i,r){var n,s=e.target,o=s._getTransformedDimensions(s.skewX,0),a=P(e,e.originX,e.originY,i,r),c=Math.abs(2*a.y)-o.y,h=s.skewY;c<2?n=0:(n=v(Math.atan2(c/s.scaleY,o.x/s.scaleX)),e.originX===f&&e.originY===p&&(n=-n),e.originX===g&&e.originY===d&&(n=-n),y(s)&&(n=-n));var l=h!==n;if(l){var u=s._getTransformedDimensions().x;s.set("skewY",n),_(s,"skewX","scaleX","x",u)}return l}function E(t,e,i,r,n){n=n||{};var s,o,a,c,h,l,u=e.target,f=u.lockScalingX,d=u.lockScalingY,g=n.by,p=w(t,u),v=k(u,g,p),m=e.gestureScale;if(v)return!1;if(m)o=e.scaleX*m,a=e.scaleY*m;else{if(s=P(e,e.originX,e.originY,i,r),h="y"!==g?T(s.x):1,l="x"!==g?T(s.y):1,e.signX||(e.signX=h),e.signY||(e.signY=l),u.lockScalingFlip&&(e.signX!==h||e.signY!==l))return!1;if(c=u._getTransformedDimensions(),p&&!g){var b=Math.abs(s.x)+Math.abs(s.y),y=e.original,_=b/(Math.abs(c.x*y.scaleX/u.scaleX)+Math.abs(c.y*y.scaleY/u.scaleY));o=y.scaleX*_,a=y.scaleY*_}else o=Math.abs(s.x*u.scaleX/c.x),a=Math.abs(s.y*u.scaleY/c.y);O(e)&&(o*=2,a*=2),e.signX!==h&&"y"!==g&&(e.originX=S[e.originX],o*=-1,e.signX=h),e.signY!==l&&"x"!==g&&(e.originY=S[e.originY],a*=-1,e.signY=l)}var x=u.scaleX,C=u.scaleY;return g?("x"===g&&u.set("scaleX",o),"y"===g&&u.set("scaleY",a)):(!f&&u.set("scaleX",o),!d&&u.set("scaleY",a)),x!==u.scaleX||C!==u.scaleY}n.scaleCursorStyleHandler=function(t,e,i){var r=w(t,i),n="";if(0!==e.x&&0===e.y?n="x":0===e.x&&0!==e.y&&(n="y"),k(i,n,r))return"not-allowed";var s=a(i,e);return o[s]+"-resize"},n.skewCursorStyleHandler=function(t,e,i){var r="not-allowed";if(0!==e.x&&i.lockSkewingY)return r;if(0!==e.y&&i.lockSkewingX)return r;var n=a(i,e)%4;return s[n]+"-resize"},n.scaleSkewCursorStyleHandler=function(t,e,i){return t[i.canvas.altActionKey]?n.skewCursorStyleHandler(t,e,i):n.scaleCursorStyleHandler(t,e,i)},n.rotationWithSnapping=b("rotating",m(function(t,e,i,r){var n=e,s=n.target,o=s.translateToOriginPoint(s.getCenterPoint(),n.originX,n.originY);if(s.lockRotation)return!1;var a,c=Math.atan2(n.ey-o.y,n.ex-o.x),h=Math.atan2(r-o.y,i-o.x),l=v(h-c+n.theta);if(0o.r2,h=this.gradientTransform?this.gradientTransform.concat():fabric.iMatrix.concat(),l=-this.offsetX,u=-this.offsetY,f=!!e.additionalTransform,d="pixels"===this.gradientUnits?"userSpaceOnUse":"objectBoundingBox";if(a.sort(function(t,e){return t.offset-e.offset}),"objectBoundingBox"===d?(l/=t.width,u/=t.height):(l+=t.width/2,u+=t.height/2),"path"===t.type&&"percentage"!==this.gradientUnits&&(l-=t.pathOffset.x,u-=t.pathOffset.y),h[4]-=l,h[5]-=u,s='id="SVGID_'+this.id+'" gradientUnits="'+d+'"',s+=' gradientTransform="'+(f?e.additionalTransform+" ":"")+fabric.util.matrixToSVG(h)+'" ',"linear"===this.type?n=["\n']:"radial"===this.type&&(n=["\n']),"radial"===this.type){if(c)for((a=a.concat()).reverse(),i=0,r=a.length;i\n')}return n.push("linear"===this.type?"\n":"\n"),n.join("")},toLive:function(t){var e,i,r,n=fabric.util.object.clone(this.coords);if(this.type){for("linear"===this.type?e=t.createLinearGradient(n.x1,n.y1,n.x2,n.y2):"radial"===this.type&&(e=t.createRadialGradient(n.x1,n.y1,n.r1,n.x2,n.y2,n.r2)),i=0,r=this.colorStops.length;i\n\n\n'},setOptions:function(t){for(var e in t)this[e]=t[e]},toLive:function(t){var e=this.source;if(!e)return"";if(void 0!==e.src){if(!e.complete)return"";if(0===e.naturalWidth||0===e.naturalHeight)return""}return t.createPattern(e,this.repeat)}})}(),function(t){"use strict";var o=t.fabric||(t.fabric={}),a=o.util.toFixed;o.Shadow?o.warn("fabric.Shadow is already defined."):(o.Shadow=o.util.createClass({color:"rgb(0,0,0)",blur:0,offsetX:0,offsetY:0,affectStroke:!1,includeDefaultValues:!0,nonScaling:!1,initialize:function(t){for(var e in"string"==typeof t&&(t=this._parseShadow(t)),t)this[e]=t[e];this.id=o.Object.__uid++},_parseShadow:function(t){var e=t.trim(),i=o.Shadow.reOffsetsAndBlur.exec(e)||[];return{color:(e.replace(o.Shadow.reOffsetsAndBlur,"")||"rgb(0,0,0)").trim(),offsetX:parseFloat(i[1],10)||0,offsetY:parseFloat(i[2],10)||0,blur:parseFloat(i[3],10)||0}},toString:function(){return[this.offsetX,this.offsetY,this.blur,this.color].join("px ")},toSVG:function(t){var e=40,i=40,r=o.Object.NUM_FRACTION_DIGITS,n=o.util.rotateVector({x:this.offsetX,y:this.offsetY},o.util.degreesToRadians(-t.angle)),s=new o.Color(this.color);return t.width&&t.height&&(e=100*a((Math.abs(n.x)+this.blur)/t.width,r)+20,i=100*a((Math.abs(n.y)+this.blur)/t.height,r)+20),t.flipX&&(n.x*=-1),t.flipY&&(n.y*=-1),'\n\t\n\t\n\t\n\t\n\t\n\t\t\n\t\t\n\t\n\n'},toObject:function(){if(this.includeDefaultValues)return{color:this.color,blur:this.blur,offsetX:this.offsetX,offsetY:this.offsetY,affectStroke:this.affectStroke,nonScaling:this.nonScaling};var e={},i=o.Shadow.prototype;return["color","blur","offsetX","offsetY","affectStroke","nonScaling"].forEach(function(t){this[t]!==i[t]&&(e[t]=this[t])},this),e}}),o.Shadow.reOffsetsAndBlur=/(?:\s|^)(-?\d+(?:\.\d*)?(?:px)?(?:\s?|$))?(-?\d+(?:\.\d*)?(?:px)?(?:\s?|$))?(\d+(?:\.\d*)?(?:px)?)?(?:\s?|$)(?:$|\s)/)}("undefined"!=typeof exports?exports:this),function(){"use strict";if(fabric.StaticCanvas)fabric.warn("fabric.StaticCanvas is already defined.");else{var n=fabric.util.object.extend,t=fabric.util.getElementOffset,h=fabric.util.removeFromArray,a=fabric.util.toFixed,s=fabric.util.transformPoint,o=fabric.util.invertTransform,i=fabric.util.getNodeCanvas,r=fabric.util.createCanvasElement,e=new Error("Could not initialize `canvas` element");fabric.StaticCanvas=fabric.util.createClass(fabric.CommonMethods,{initialize:function(t,e){e||(e={}),this.renderAndResetBound=this.renderAndReset.bind(this),this.requestRenderAllBound=this.requestRenderAll.bind(this),this._initStatic(t,e)},backgroundColor:"",backgroundImage:null,overlayColor:"",overlayImage:null,includeDefaultValues:!0,stateful:!1,renderOnAddRemove:!0,controlsAboveOverlay:!1,allowTouchScrolling:!1,imageSmoothingEnabled:!0,viewportTransform:fabric.iMatrix.concat(),backgroundVpt:!0,overlayVpt:!0,enableRetinaScaling:!0,vptCoords:{},skipOffscreen:!0,clipPath:void 0,_initStatic:function(t,e){var i=this.requestRenderAllBound;this._objects=[],this._createLowerCanvas(t),this._initOptions(e),this.interactive||this._initRetinaScaling(),e.overlayImage&&this.setOverlayImage(e.overlayImage,i),e.backgroundImage&&this.setBackgroundImage(e.backgroundImage,i),e.backgroundColor&&this.setBackgroundColor(e.backgroundColor,i),e.overlayColor&&this.setOverlayColor(e.overlayColor,i),this.calcOffset()},_isRetinaScaling:function(){return 1\n'),this._setSVGBgOverlayColor(i,"background"),this._setSVGBgOverlayImage(i,"backgroundImage",e),this._setSVGObjects(i,e),this.clipPath&&i.push("\n"),this._setSVGBgOverlayColor(i,"overlay"),this._setSVGBgOverlayImage(i,"overlayImage",e),i.push(""),i.join("")},_setSVGPreamble:function(t,e){e.suppressPreamble||t.push('\n','\n')},_setSVGHeader:function(t,e){var i,r=e.width||this.width,n=e.height||this.height,s='viewBox="0 0 '+this.width+" "+this.height+'" ',o=fabric.Object.NUM_FRACTION_DIGITS;e.viewBox?s='viewBox="'+e.viewBox.x+" "+e.viewBox.y+" "+e.viewBox.width+" "+e.viewBox.height+'" ':this.svgViewportTransformation&&(i=this.viewportTransform,s='viewBox="'+a(-i[4]/i[0],o)+" "+a(-i[5]/i[3],o)+" "+a(this.width/i[0],o)+" "+a(this.height/i[3],o)+'" '),t.push("