Maki7 commited on
Commit
704a244
1 Parent(s): c2922a2

Upload 42 files

Browse files
Files changed (42) hide show
  1. .gitignore +8 -0
  2. CHANGELOG.md +62 -0
  3. LICENSE +21 -0
  4. README.md +138 -0
  5. backend/__init__.py +3 -0
  6. backend/app.py +386 -0
  7. backend/config.py +154 -0
  8. backend/script_hack.py +161 -0
  9. backend/structs.py +85 -0
  10. backend/utils.py +329 -0
  11. frontends/krita/krita_diff.desktop +8 -0
  12. frontends/krita/krita_diff/__init__.py +73 -0
  13. frontends/krita/krita_diff/client.py +402 -0
  14. frontends/krita/krita_diff/config.py +87 -0
  15. frontends/krita/krita_diff/defaults.py +127 -0
  16. frontends/krita/krita_diff/docker.py +34 -0
  17. frontends/krita/krita_diff/extension.py +63 -0
  18. frontends/krita/krita_diff/krita_diff.action +69 -0
  19. frontends/krita/krita_diff/manual.html +15 -0
  20. frontends/krita/krita_diff/pages/__init__.py +6 -0
  21. frontends/krita/krita_diff/pages/common.py +123 -0
  22. frontends/krita/krita_diff/pages/config.py +182 -0
  23. frontends/krita/krita_diff/pages/extension.py +135 -0
  24. frontends/krita/krita_diff/pages/img2img.py +31 -0
  25. frontends/krita/krita_diff/pages/img_base.py +90 -0
  26. frontends/krita/krita_diff/pages/inpaint.py +97 -0
  27. frontends/krita/krita_diff/pages/txt2img.py +49 -0
  28. frontends/krita/krita_diff/pages/upscale.py +60 -0
  29. frontends/krita/krita_diff/script.py +425 -0
  30. frontends/krita/krita_diff/style.py +8 -0
  31. frontends/krita/krita_diff/utils.py +237 -0
  32. frontends/krita/krita_diff/widgets/__init__.py +8 -0
  33. frontends/krita/krita_diff/widgets/checkbox.py +81 -0
  34. frontends/krita/krita_diff/widgets/combo_box.py +98 -0
  35. frontends/krita/krita_diff/widgets/line_edit.py +44 -0
  36. frontends/krita/krita_diff/widgets/misc.py +13 -0
  37. frontends/krita/krita_diff/widgets/prompt.py +58 -0
  38. frontends/krita/krita_diff/widgets/spin_box.py +64 -0
  39. frontends/krita/krita_diff/widgets/status_bar.py +22 -0
  40. frontends/krita/krita_diff/widgets/tips.py +18 -0
  41. install.py +26 -0
  42. scripts/main.py +146 -0
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__
2
+ /venv
3
+ /tmp
4
+ /outputs
5
+ /log
6
+ /.idea
7
+ /.vscode
8
+ /krita_config.yaml
CHANGELOG.md ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # UI Changelog
2
+
3
+ ## 2022-12-28
4
+
5
+ - Added "Alt Dock Behaviour" under "SD Plugin Config".
6
+ - _Modifies default Krita dock behaviour!_
7
+ - Dragging title bar of docker now drags all stacked/tabbed dockers out instead of just one docker.
8
+ - Dragging the tab now drags the specific docker out instead of only re-arranging the tab.
9
+ - Enables floating stacked/tabbed dockers.
10
+ - Enables subdividing dock areas further.
11
+ - See: <https://doc.qt.io/qt-6/qmainwindow.html#DockOption-enum>
12
+ - All generations are added to group layer per batch with generation info.
13
+ - For batches of generations, all but the last image generated is hidden by default.
14
+
15
+ ## 2022-12-20
16
+
17
+ - **UI Overhaul**: A few miscellaneous changes with some big ones:
18
+ - All tabs are now their own dockers to allow more flexibility in arranging.
19
+ - "Restore Defaults" will make all dockers re-appear and arrange themselves.
20
+ - Progress & number of pending requests now shown.
21
+ - All dropdowns now support searching, useful if your model checkpoint list is really long.
22
+
23
+ ## 2022-12-04
24
+
25
+ - Add Interrupt button.
26
+
27
+ ## 2022-11-15
28
+
29
+ - Scripts/features that increase the image size (Simple upscaling, SD upscaling, Outpaint Mk 2, etc) will now expand the canvas when image generation is complete **only if** _there is no active selection_.
30
+ - If there is a selection, the image will be scaled to fit the selection region.
31
+ - Using Ctrl+A to select the entire image is considered an active selection!
32
+
33
+ ## 2022-11-08
34
+
35
+ - Inpainting is finally 100% fixed! No more weird borders. Blur works properly.
36
+ - Inpainting Full Resolution and Mask Blur were deemed obsolete and removed.
37
+ - See <https://github.com/Interpause/auto-sd-paint-ext/wiki/Usage-Guide#inpainting> on better ways to do so.
38
+
39
+ ## 2022-10-31
40
+
41
+ - Moved base size/max size & some other quick config options based on user feedback.
42
+
43
+ ## 2022-10-25
44
+
45
+ - Will now save previous tab user was on.
46
+ - Fixed seed being truncated to 32-bit int.
47
+ - Prevent sending image generation request when cannot connect to backend.
48
+
49
+ ## 2022-10-24
50
+
51
+ - UI no longer freezes when generating images or network activity like getting backend config
52
+ - Pressing "start xxx" multiple times will queue generation requests on the backend
53
+ - Will not mess with the current selection region or layer when inserting images once done
54
+
55
+ ## 2022-10-21
56
+
57
+ - No need to manually hide inpainting layer anymore; It will be auto-hidden.
58
+ - Color correction can be toggled separately for img2img/inpainting.
59
+ - Status bar:
60
+ - In middle of page to be more visible even when scrolling.
61
+ - Warning when using features with no document open.
62
+ - Inpaint is now the default tab.
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2022 John-Henry Lim
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # auto-sd-paint-ext
2
+
3
+ Formerly known as `auto-sd-krita`.
4
+
5
+ > Extension for AUTOMATIC1111's webUI with Krita Plugin (other drawing studios soon?)
6
+
7
+ Outdated demo | New UI (TODO: demo image)
8
+ --- | ---
9
+ ![demo image](https://user-images.githubusercontent.com/42513874/194701722-e7a3f7eb-be4a-4f43-93a5-480835c9260f.jpg) | ![demo image 2](https://user-images.githubusercontent.com/42513874/199507299-66729f9b-3581-43a3-b5f4-57eb90b8f981.png)
10
+
11
+ Why use this?
12
+
13
+ - Optimized workflow (txt2img, img2img, inpaint, outpaint, upscale) & UI design.
14
+ - Only drawing studio plugin that exposes the Script API.
15
+ - Easily create/save profiles (prompts, samplers, model, etc used).
16
+ - Some of the above isn't actually implemented yet.
17
+
18
+ ## Quick Jump
19
+
20
+ - Full Installation & Workflow Tutorial Video! (Coming Soon...)
21
+ - [Installation Guide](https://github.com/Interpause/auto-sd-paint-ext/wiki/Install-Guide)
22
+ - [Usage Guide](https://github.com/Interpause/auto-sd-paint-ext/wiki/Usage-Guide)
23
+ - [Step by Step Guide to Better Inpainting](https://github.com/Interpause/auto-sd-paint-ext/wiki/Usage-Guide#inpainting-step-by-step)
24
+ - [Update Guide](https://github.com/Interpause/auto-sd-paint-ext/wiki/Update-Guide)
25
+ - [Features](https://github.com/Interpause/auto-sd-paint-ext/wiki/Features)
26
+ - [TODO](https://github.com/Interpause/auto-sd-paint-ext/wiki/TODO)
27
+ - [Contribution Guide](https://github.com/Interpause/auto-sd-paint-ext/wiki/Contribution-Guide)
28
+
29
+ (Outdated) Usage & Workflow Demo:
30
+
31
+ [![Youtube Video](http://img.youtube.com/vi/nP8MuRwcDN8/0.jpg)](https://youtu.be/nP8MuRwcDN8 "Inpaint like a pro with Stable Diffusion! auto-sd-krita workflow guide")
32
+
33
+ ### Differences from Video
34
+
35
+ - All webUI scripts have been tested to work!
36
+ - SD Upscale, Outpainting Mk 2, Img2Img Alt, etc
37
+ - Inpainting experience is better
38
+ - Inpaint mask is auto-hidden
39
+ - Better mask blur & inpaint full resolution technique than webUI
40
+ - UI no longer freezes during image update
41
+ - UI has been improved, takes up less space
42
+ - Error messages have been improved
43
+
44
+ ## Breaking Changes
45
+
46
+ - The URL is different now, so reset "Backend URL" to default under the Config tab.
47
+ - It is now an AUTOMATIC1111 extension.
48
+ - Do <https://github.com/Interpause/auto-sd-krita/wiki/Quick-Switch-Using-Existing-AUTOMATIC1111-Install> in reverse for a quick fix.
49
+ - `krita_config.yaml` was renamed to `auto-sd-paint-ext-backend.yaml`.
50
+
51
+ ## FAQ
52
+
53
+ Q: How does the base_size, max_size system work?
54
+
55
+ A:
56
+
57
+ It is an alternative to AUTO's highres fix that works for all modes, not just txt2img.
58
+
59
+ The selection will be resized such that the shorter dimension is base_size. However, if the aforementioned resize causes the longer dimension to exceed max_size, the shorter dimension will be resized to less than base_size. Setting base_size and max_size higher can be used to generate higher resolution images (along with their issues), essentially **disabling the system**, _though it might make sense for img2img mode_.
60
+
61
+ This is actually smarter than the builtin highres fix + firstphase width/height system. Thank the original plugin writer, @sddebz, for writing this.
62
+
63
+ <hr/>
64
+
65
+ Q: Outpainting tab?
66
+
67
+ A:
68
+ While the outpainting tab is still WIP, the outpainting scripts (under img2img tab) works perfectly fine! Alternatively, if you want more control over outpainting, you can:
69
+
70
+ 1. Expand the canvas
71
+ 2. Scribble in the newly added blank area
72
+ 3. img2img on the blank area + some of the image
73
+
74
+ <hr/>
75
+
76
+ Q: Is the model loaded into memory twice?
77
+
78
+ A: No, it shares the same backend. Both the Krita plugin and webUI can be used concurrently.
79
+
80
+ <hr/>
81
+
82
+ Q: How can you commit to updating regularly?
83
+
84
+ A: It is easy for me.
85
+
86
+ <hr/>
87
+
88
+ Q: Will it work with other Krita plugin backends?
89
+
90
+ A: Unfortunately no, all plugins so far have different APIs. The official API is coming soon though...
91
+
92
+ ## UI Changelog
93
+
94
+ See [CHANGELOG.md](./CHANGELOG.md) for the full changelog.
95
+
96
+ ### 2022-12-28
97
+
98
+ - Added "Alt Dock Behaviour" under "SD Plugin Config".
99
+ - _Modifies default Krita dock behaviour!_
100
+ - Dragging title bar of docker now drags all stacked/tabbed dockers out instead of just one docker.
101
+ - Dragging the tab now drags the specific docker out instead of only re-arranging the tab.
102
+ - Enables floating stacked/tabbed dockers.
103
+ - Enables subdividing dock areas further.
104
+ - See: <https://doc.qt.io/qt-6/qmainwindow.html#DockOption-enum>
105
+ - All generations are added to group layer per batch with generation info.
106
+ - For batches of generations, all but the last image generated is hidden by default.
107
+
108
+ ### 2022-12-20
109
+
110
+ - **UI Overhaul**: A few miscellaneous changes with some big ones:
111
+ - All tabs are now their own dockers to allow more flexibility in arranging.
112
+ - "Restore Defaults" will make all dockers re-appear and arrange themselves.
113
+ - Progress & number of pending requests now shown.
114
+ - All dropdowns now support searching, useful if your model checkpoint list is really long.
115
+
116
+ ### 2022-12-04
117
+
118
+ - Add Interrupt button.
119
+
120
+ ### 2022-11-15
121
+
122
+ - Scripts/features that increase the image size (Simple upscaling, SD upscaling, Outpaint Mk 2, etc) will now expand the canvas when image generation is complete **only if** _there is no active selection_.
123
+ - If there is a selection, the image will be scaled to fit the selection region.
124
+ - Using Ctrl+A to select the entire image is considered an active selection!
125
+
126
+ ### 2022-11-08
127
+
128
+ - Inpainting is finally 100% fixed! No more weird borders. Blur works properly.
129
+ - Inpainting Full Resolution and Mask Blur were deemed obsolete and removed.
130
+ - See <https://github.com/Interpause/auto-sd-paint-ext/wiki/Usage-Guide#inpainting> on better ways to do so.
131
+
132
+ ## Credits
133
+
134
+ - [@sddebz](https://github.com/sddebz) for writing the original backend API and Krita plugin while keeping the Gradio webUI functionality intact.
135
+
136
+ ## License
137
+
138
+ MIT for the Krita Plugin backend server & frontend plugin. Code has been nearly completely rewritten compared to original plugin by now.
backend/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .app import router
2
+
3
+ __all__ = ["router"]
backend/app.py ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ import time
6
+
7
+ import modules
8
+ from fastapi import APIRouter, Request
9
+ from fastapi.responses import StreamingResponse
10
+ from modules import shared
11
+ from modules.call_queue import wrap_gradio_gpu_call
12
+ from PIL import Image, ImageOps
13
+ from starlette.concurrency import iterate_in_threadpool
14
+
15
+ from .config import LOGGER_NAME, NAME_SCRIPT_LOOPBACK, NAME_SCRIPT_UPSCALE
16
+ from .script_hack import get_script_info, get_scripts_metadata, process_script_args
17
+ from .structs import (
18
+ ConfigResponse,
19
+ ImageResponse,
20
+ Img2ImgRequest,
21
+ Txt2ImgRequest,
22
+ UpscaleRequest,
23
+ UpscaleResponse,
24
+ )
25
+ from .utils import (
26
+ b64_to_img,
27
+ bytewise_xor,
28
+ get_encrypt_key,
29
+ get_sampler_index,
30
+ get_upscaler_index,
31
+ img_to_b64,
32
+ load_config,
33
+ merge_default_config,
34
+ parse_prompt,
35
+ prepare_backend,
36
+ prepare_mask,
37
+ save_img,
38
+ sddebz_highres_fix,
39
+ )
40
+
41
+ router = APIRouter()
42
+
43
+ log = logging.getLogger(LOGGER_NAME)
44
+
45
+ # NOTE: how to run a script
46
+ # - get scripts_txt2img/scripts_img2img from modules.scripts
47
+ # - construct array args, where 0th element is selected script
48
+ # - refer to script.args_from & script.args_to to figure out which elements in
49
+ # array args to populate
50
+ #
51
+ # The way scripts are handled is they are loaded one by one, append to a list of
52
+ # scripts, which each script taking up "slots" in the input args array.
53
+ # So the more scripts, the longer array args would be for the last script.
54
+
55
+ # NOTE: where to draw the line on what is done by the backend vs the frontend?
56
+ # TODO: Create separate Outpainting route, add img2img structs to Upscale route
57
+ # - yes I know its highly inconsistent what should be a route or not, but to prevent
58
+ # incredibly hacky workarounds on the frontend for script calling, it should be
59
+ # done by the backend, which has better access to the script information.
60
+ # - Upscale tab UI:
61
+ # - Upscaler dropdown + 0.5x downscale checkbox + SD upscale checkbox
62
+ # - SD upscale checkbox hides 0.5x downscale checkbox, renames upscaler dropdown
63
+ # - to prescaler, and shows modified img2img UI (ofc uses its own cfg namespace)
64
+ # - Outpaint tab UI:
65
+ # - modified img2img UI with own cfg namespace
66
+ # - try and hijack more control (Pixel to expand per direction instead of all directions)
67
+ # - self-sketch mode: basically sketch + inpaint but the inpaint mask is auto-calculated
68
+ # - option to select poor man, mk 2 or self-sketch
69
+ # TODO: Consider using pipeline directly instead of Gradio API for less surprises & better control
70
+
71
+
72
+ @router.get("/config", response_model=ConfigResponse)
73
+ async def get_state():
74
+ """Get information about backend API.
75
+
76
+ Returns config from `krita_config.yaml`, other metadata,
77
+ the path to the rendered image and image mask, etc.
78
+
79
+ Returns:
80
+ Dict: information.
81
+ """
82
+ opt = load_config().plugin
83
+ prepare_backend(opt)
84
+
85
+ sample_path = os.path.abspath(opt.sample_path)
86
+ return {
87
+ **opt.dict(),
88
+ "sample_path": sample_path,
89
+ "upscalers": [upscaler.name for upscaler in shared.sd_upscalers],
90
+ "samplers": [sampler.name for sampler in modules.sd_samplers.samplers],
91
+ "samplers_img2img": [
92
+ sampler.name for sampler in modules.sd_samplers.samplers_for_img2img
93
+ ],
94
+ "scripts_txt2img": get_scripts_metadata(False),
95
+ "scripts_img2img": get_scripts_metadata(True),
96
+ "face_restorers": [model.name() for model in shared.face_restorers],
97
+ "sd_models": modules.sd_models.checkpoint_tiles(), # yes internal API has spelling error
98
+ }
99
+
100
+
101
+ @router.post("/txt2img", response_model=ImageResponse)
102
+ def f_txt2img(req: Txt2ImgRequest):
103
+ """Post request for Txt2Img.
104
+
105
+ Args:
106
+ req (Txt2ImgRequest): Request.
107
+
108
+ Returns:
109
+ Dict: Outputs and info.
110
+ """
111
+ log.info(f"txt2img:\n{req}")
112
+
113
+ opt = load_config().txt2img
114
+ req = merge_default_config(req, opt)
115
+ prepare_backend(req)
116
+
117
+ script_ind, script, meta = get_script_info(req.script, False)
118
+ args = process_script_args(script_ind, script, meta, req.script_args)
119
+
120
+ width, height = sddebz_highres_fix(
121
+ req.base_size, req.max_size, req.orig_width, req.orig_height
122
+ )
123
+
124
+ output = wrap_gradio_gpu_call(modules.txt2img.txt2img)(
125
+ parse_prompt(req.prompt), # prompt
126
+ parse_prompt(req.negative_prompt), # negative_prompt
127
+ "None", # prompt_style: saved prompt styles (unsupported)
128
+ "None", # prompt_style2: saved prompt styles (unsupported)
129
+ req.steps, # steps
130
+ get_sampler_index(req.sampler_name), # sampler_index
131
+ req.restore_faces, # restore_faces
132
+ req.tiling, # tiling
133
+ req.batch_count, # n_iter
134
+ req.batch_size, # batch_size
135
+ req.cfg_scale, # cfg_scale
136
+ req.seed, # seed
137
+ req.subseed, # subseed
138
+ req.subseed_strength, # subseed_strength
139
+ req.seed_resize_from_h, # seed_resize_from_h
140
+ req.seed_resize_from_w, # seed_resize_from_w
141
+ req.seed_enable_extras, # seed_enable_extras
142
+ height, # height
143
+ width, # width
144
+ req.highres_fix, # enable_hr: high res fix
145
+ req.denoising_strength, # denoising_strength: only applicable if high res fix in use
146
+ 0, # hr_scale (overrided by hr_resize_x/y)
147
+ req.upscaler_name, # hr_upscaler: upscaler to use for highres fix
148
+ 0, # hr_second_pass_steps: 0 uses same num of steps as generation to refine details
149
+ req.orig_width, # hr_resize_x
150
+ req.orig_height, # hr_resize_y
151
+ *args,
152
+ )
153
+ images = output[0]
154
+ info = output[1]
155
+
156
+ if images is None or len(images) < 1:
157
+ log.warning("Interrupted!")
158
+ return {"outputs": [], "info": info}
159
+
160
+ if shared.opts.return_grid:
161
+ if not req.include_grid and len(images) > 1 and script_ind == 0:
162
+ images = images[1:]
163
+
164
+ if not script or (width == images[0].width and height == images[0].height):
165
+ log.info(
166
+ f"img size: {images[0].width}x{images[0].height}, target: {req.orig_width}x{req.orig_height}"
167
+ )
168
+ images = [
169
+ modules.images.resize_image(0, image, req.orig_width, req.orig_height)
170
+ for image in images
171
+ ]
172
+
173
+ # save images for debugging/logging purposes
174
+ if req.save_samples:
175
+ output_paths = [
176
+ save_img(image, opt.sample_path, filename=f"{int(time.time())}_{i}.png")
177
+ for i, image in enumerate(images)
178
+ ]
179
+ log.info(f"saved: {output_paths}")
180
+
181
+ images = [img_to_b64(image) for image in images]
182
+
183
+ log.info(f"output sizes: {[len(i) for i in images]}")
184
+ log.info(f"finished txt2img!")
185
+ return {"outputs": images, "info": info}
186
+
187
+
188
+ @router.post("/img2img", response_model=ImageResponse)
189
+ def f_img2img(req: Img2ImgRequest):
190
+ """Post request for Img2Img.
191
+
192
+ Args:
193
+ req (Img2ImgRequest): Request.
194
+
195
+ Returns:
196
+ Dict: Outputs and info.
197
+ """
198
+ log.info(f"img2img:\n{req.dict(exclude={'src_img', 'mask_img'})}")
199
+
200
+ opt = load_config().img2img
201
+ req = merge_default_config(req, opt)
202
+ prepare_backend(req)
203
+
204
+ script_ind, script, meta = get_script_info(req.script, True)
205
+ args = process_script_args(script_ind, script, meta, req.script_args)
206
+
207
+ image = b64_to_img(req.src_img)
208
+ mask = (
209
+ prepare_mask(b64_to_img(req.mask_img))
210
+ if req.mode == 1 and req.mask_img is not None
211
+ else None
212
+ )
213
+
214
+ orig_width, orig_height = image.size
215
+
216
+ if script and script.title() == NAME_SCRIPT_UPSCALE:
217
+ # in SD upscale mode, width & height determines tile size
218
+ width = height = req.base_size
219
+ else:
220
+ width, height = sddebz_highres_fix(
221
+ req.base_size, req.max_size, orig_width, orig_height
222
+ )
223
+
224
+ # NOTE:
225
+ # - image & mask repeated due to Gradio API have separate tabs for each mode...
226
+ # - mask is used only in inpaint mode
227
+ # - mask_mode determines whethere init_img_with_mask or init_img_inpaint is used,
228
+ # I dont know why
229
+ # - new color sketch functionality in webUI is irrelevant so None is used for their options.
230
+ # - the internal code for img2img is confusing and duplicative...
231
+
232
+ output = wrap_gradio_gpu_call(modules.img2img.img2img)(
233
+ req.mode, # mode
234
+ parse_prompt(req.prompt), # prompt
235
+ parse_prompt(req.negative_prompt), # negative_prompt
236
+ "None", # prompt_style: saved prompt styles (unsupported)
237
+ "None", # prompt_style2: saved prompt styles (unsupported)
238
+ image, # init_img
239
+ {"image": image, "mask": mask}, # init_img_with_mask
240
+ None, # init_img_with_mask_orig # only used by webUI color sketch if init_img_with_mask isn't dict
241
+ image, # init_img_inpaint
242
+ mask, # init_mask_inpaint
243
+ # using 1 for uploaded mask mode; processing done by prepare_mask to ensure its correct
244
+ 1, # mask_mode: internally checks if equal 0. 1 enables alpha mask (remove erased parts)
245
+ req.steps, # steps
246
+ get_sampler_index(req.sampler_name), # sampler_index
247
+ 0, # req.mask_blur, # mask_blur
248
+ None, # mask_alpha # only used by webUI color sketch if init_img_with_mask isn't dict
249
+ req.inpainting_fill, # inpainting_fill
250
+ req.restore_faces, # restore_faces
251
+ req.tiling, # tiling
252
+ req.batch_count, # n_iter
253
+ req.batch_size, # batch_size
254
+ req.cfg_scale, # cfg_scale
255
+ req.denoising_strength, # denoising_strength
256
+ req.seed, # seed
257
+ req.subseed, # subseed
258
+ req.subseed_strength, # subseed_strength
259
+ req.seed_resize_from_h, # seed_resize_from_h
260
+ req.seed_resize_from_w, # seed_resize_from_w
261
+ req.seed_enable_extras, # seed_enable_extras
262
+ height, # height
263
+ width, # width
264
+ req.resize_mode, # resize_mode
265
+ False, # req.inpaint_full_res, # inpaint_full_res
266
+ 0, # req.inpaint_full_res_padding, # inpaint_full_res_padding
267
+ req.invert_mask, # inpainting_mask_invert
268
+ "", # img2img_batch_input_dir (unspported)
269
+ "", # img2img_batch_output_dir (unspported)
270
+ *args,
271
+ )
272
+ images = output[0]
273
+ info = output[1]
274
+
275
+ if images is None or len(images) < 1:
276
+ log.warning("Interrupted!")
277
+ return {"outputs": [], "info": info}
278
+
279
+ if shared.opts.return_grid:
280
+ if not req.include_grid and len(images) > 1 and script_ind == 0:
281
+ images = images[1:]
282
+ # This is a workaround.
283
+ if script and script.title() == NAME_SCRIPT_LOOPBACK and len(images) > 1:
284
+ images = images[1:]
285
+
286
+ # NOTE: this is a dumb assumption:
287
+ # if size of image is different from size given to pipeline (after sbbedz fix)
288
+ # then it must be intentional (i.e. SD Upscale/outpaint) so dont scale back
289
+ if not script or (width == images[0].width and height == images[0].height):
290
+ log.info(
291
+ f"img Size: {images[0].width}x{images[0].height}, target: {orig_width}x{orig_height}"
292
+ )
293
+ images = [
294
+ modules.images.resize_image(0, image, orig_width, orig_height)
295
+ for image in images
296
+ ]
297
+
298
+ if req.mode == 1:
299
+
300
+ def apply_mask(img):
301
+ """Mask inpaint using original mask, including alpha."""
302
+ r, g, b = img.split() # img2img/inpaint gives rgb image
303
+ a = ImageOps.invert(mask) if req.invert_mask else mask
304
+ return Image.merge("RGBA", (r, g, b, a))
305
+
306
+ images = [apply_mask(x) for x in images]
307
+
308
+ # save images for debugging/logging purposes
309
+ if req.save_samples:
310
+ output_paths = [
311
+ save_img(image, opt.sample_path, filename=f"{int(time.time())}_{i}.png")
312
+ for i, image in enumerate(images)
313
+ ]
314
+ log.info(f"saved: {output_paths}")
315
+
316
+ images = [img_to_b64(image) for image in images]
317
+
318
+ log.info(f"output sizes: {[len(i) for i in images]}")
319
+ log.info(f"finished img2img!")
320
+ return {"outputs": images, "info": info}
321
+
322
+
323
+ @router.post("/upscale", response_model=UpscaleResponse)
324
+ def f_upscale(req: UpscaleRequest):
325
+ """Post request for upscaling.
326
+
327
+ Args:
328
+ req (UpscaleRequest): Request.
329
+
330
+ Returns:
331
+ Dict: Output.
332
+ """
333
+ log.info(f"upscale:\n{req.dict(exclude={'src_img'})}")
334
+
335
+ opt = load_config().upscale
336
+ req = merge_default_config(req, opt)
337
+ prepare_backend(req)
338
+
339
+ image = b64_to_img(req.src_img).convert("RGB")
340
+ orig_width, orig_height = image.size
341
+
342
+ upscaler_index = get_upscaler_index(req.upscaler_name)
343
+ upscaler = shared.sd_upscalers[upscaler_index]
344
+
345
+ if upscaler.name == "None":
346
+ log.info(f"No upscaler selected, will do nothing")
347
+ return
348
+
349
+ if req.downscale_first:
350
+ image = modules.images.resize_image(0, image, orig_width // 2, orig_height // 2)
351
+
352
+ image = upscaler.scaler.upscale(image, upscaler.scale, upscaler.data_path)
353
+ if req.save_samples:
354
+ output_path = save_img(
355
+ image, opt.sample_path, filename=f"{int(time.time())}.png"
356
+ )
357
+ log.info(f"saved: {output_path}")
358
+
359
+ output = img_to_b64(image)
360
+ log.info(f"output size: {len(output)}")
361
+ log.info("finished upscale!")
362
+ return {"output": output}
363
+
364
+
365
+ async def app_encryption_middleware(req: Request, call_next):
366
+ """Used to decrypt/encrypt HTTP request body."""
367
+ is_encrypted = "X-Encrypted-Body" in req.headers
368
+ # only supported method now is XOR
369
+ assert not is_encrypted or req.headers["X-Encrypted-Body"] == "XOR"
370
+ if is_encrypted:
371
+ key = get_encrypt_key()
372
+ assert key is not None, "Unable to decrypt request without key."
373
+ body = await req.body()
374
+ body = bytewise_xor(body, key)
375
+ # NOTE: FastAPI refuses to work with requests that have already been consumed idk why
376
+ async def receive():
377
+ return dict(type="http.request", body=body, more_body=False)
378
+
379
+ req = Request(req.scope, receive, req._send)
380
+
381
+ res: StreamingResponse = await call_next(req)
382
+ if is_encrypted:
383
+ res.headers["X-Encrypted-Body"] = req.headers["X-Encrypted-Body"]
384
+ body = [bytewise_xor(chunk, key) async for chunk in res.body_iterator]
385
+ res.body_iterator = iterate_in_threadpool(iter(body))
386
+ return res
backend/config.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+ SCRIPT_NAME = "Interpause Backend API"
8
+ SCRIPT_ID = "interpause_backend_api"
9
+ ROUTE_PREFIX = "/sdapi/interpause"
10
+ CONFIG_PATH = "auto-sd-paint-ext-backend.yaml"
11
+ LOGGER_NAME = "auto-sd-paint-ext"
12
+ ENCRYPT_FILE = "xor_pass.txt"
13
+
14
+ # names of scripts to apply workarounds for
15
+ NAME_SCRIPT_LOOPBACK = "Loopback"
16
+ NAME_SCRIPT_UPSCALE = "SD upscale"
17
+
18
+
19
+ class BaseOptions(BaseModel):
20
+ sample_path: str = "outputs/krita-out"
21
+ """Where to save generated images to."""
22
+ save_samples: bool = False
23
+ """Whether to save temporary images (useful for debugging)."""
24
+
25
+
26
+ class GenerationOptions(BaseModel):
27
+ sd_model: str = "model.ckpt"
28
+ """Model to use for generation."""
29
+ script: str = "None"
30
+ """Which script to use."""
31
+ script_args: list = Field(default_factory=list)
32
+ """List of args for script."""
33
+
34
+ prompt: Any = "dog"
35
+ """Requested prompt."""
36
+ negative_prompt: Any = ""
37
+ """Requested negative prompt."""
38
+ seed: int = -1
39
+ """Seed used for noise generation. Incremented by 1 for each image rendered."""
40
+
41
+ seed_enable_extras: bool = False
42
+ """Enable subseed variation."""
43
+ subseed: int = -1
44
+ """Subseed to use for subseed variation. Incremented by 1 for each image rendered."""
45
+ subseed_strength: float = 0.0
46
+ """Strength of subseed compared to seed. 0.0 will be completely original seed, 1.0 will be completely subseed."""
47
+ seed_resize_from_h: int = 0
48
+ """Original resolution seed was used at. Used to resize latent noise to attempt to generate same image with a different resolution."""
49
+ seed_resize_from_w: int = 0
50
+ """Original resolution seed was used at. Used to resize latent noise to attempt to generate same image with a different resolution."""
51
+
52
+ sampler_name: str = "Euler a"
53
+ """Exact name of sampler to use. Name should follow exact spelling and capitalization as in the WebUI."""
54
+ steps: int = 30
55
+ """Number of steps for diffusion."""
56
+ cfg_scale: float = 7.5
57
+ """Guidance scale for diffusion."""
58
+ denoising_strength: float = 0.35
59
+ """Strength of denoising from 0.0 to 1.0."""
60
+
61
+ batch_count: int = 1
62
+ """Number of batches to render."""
63
+ batch_size: int = 1
64
+ """Number of images per batch to render."""
65
+
66
+ base_size: int = 512
67
+ """Native/base resolution of model used."""
68
+ max_size: int = 768
69
+ """Max input resolution allowed to prevent image artifacts."""
70
+ tiling: bool = False
71
+ """Whether to generate a tileable image."""
72
+ highres_fix: bool = False
73
+ """Whether to enable workaround for higher resolution at cost of time."""
74
+ firstphase_height: int = 512
75
+ """Max height for first phase of highres fix (before upscaling to requested resolution)."""
76
+ firstphase_width: int = 512
77
+ """Max width for first phase of highres fix (before upscaling to requested resolution)."""
78
+
79
+ # upscale_overlap: int = 64
80
+ # """Size of overlap in pixels for upscaling.""" Configure this in WebUI
81
+ upscaler_name: str = "None"
82
+ """Exact name of upscaler to use."""
83
+ filter_nsfw: bool = False
84
+ """filter NSFW content."""
85
+
86
+ include_grid: bool = False
87
+ """Whether to include the image grid in the results sent to Krita"""
88
+
89
+
90
+ class SamplerParamOptions(BaseModel):
91
+ # TODO: More conveniently expose config options for samplers/explain them.
92
+ pass
93
+
94
+
95
+ class FaceRestorationOptions(BaseModel):
96
+ restore_faces: bool = False
97
+ """Whether to use GFPGAN for face restoration."""
98
+ face_restorer: str = "CodeFormer"
99
+ """Exact name of face restorer to use."""
100
+ codeformer_weight: float = 0.5
101
+ """Strength of face restoration if using CodeFormer. 0.0 is the strongest and 1.0 is the weakest."""
102
+
103
+
104
+ class InpaintingOptions(BaseModel):
105
+ inpainting_fill: int = 1
106
+ """What to fill inpainted region with. 0 is blur/fill, 1 is original, 2 is latent noise, and 3 is latent empty."""
107
+ inpaint_full_res: bool = False
108
+ """(DISABLED) Whether to use the full resolution for inpainting."""
109
+ inpaint_full_res_padding: int = 0
110
+ """(DISABLED) Padding when using full resolution for inpainting."""
111
+ mask_blur: int = 0
112
+ """(DISABLED) Size of blur at boundaries of mask."""
113
+ invert_mask: bool = False
114
+ """Whether to invert the mask."""
115
+ inpaint_mask_weight: float = 1.0
116
+ """Mask weight for specialized inpainting models."""
117
+
118
+
119
+ class Txt2ImgOptions(BaseOptions, GenerationOptions, FaceRestorationOptions):
120
+ pass
121
+
122
+
123
+ class Img2ImgOptions(
124
+ BaseOptions, GenerationOptions, InpaintingOptions, FaceRestorationOptions
125
+ ):
126
+ mode: int = 0
127
+ """Img2Img mode. 0 is normal img2img on the selected region, 1 is inpainting, and 2 (unsupported) is batch processing."""
128
+ resize_mode: int = 1
129
+ """Unused by Krita plugin since rescaling is done by us. 0 is stretch to fit, 1 is cover, 2 is contain."""
130
+
131
+ steps: int = 50
132
+
133
+ color_correct: bool = True
134
+ """Apply color correction after img2img/inpaint to match original & blend better."""
135
+ do_exact_steps: bool = True
136
+ """Do exactly the number of steps specified by the slider instead of less during img2img/inpaint."""
137
+
138
+
139
+ class UpscaleOptions(BaseOptions):
140
+ upscaler_name: str = "None"
141
+ """Exact name of upscaler to use."""
142
+ downscale_first: bool = False
143
+ """Whether to downscale the image by x0.5 first."""
144
+
145
+
146
+ class PluginOptions(BaseOptions):
147
+ sample_path: str = "outputs/krita-in"
148
+
149
+
150
+ class MainConfig(BaseModel):
151
+ txt2img: Txt2ImgOptions = Txt2ImgOptions()
152
+ img2img: Img2ImgOptions = Img2ImgOptions()
153
+ upscale: UpscaleOptions = UpscaleOptions()
154
+ plugin: PluginOptions = PluginOptions()
backend/script_hack.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Attempting to map Gradio UI elements present in scripts to allow
3
+ converting to pyQt elements on the plugin side.
4
+ """
5
+
6
+ import logging
7
+ from typing import List, Sequence, Tuple
8
+
9
+ import gradio as gr
10
+ import modules
11
+
12
+ from .config import LOGGER_NAME
13
+
14
+ log = logging.getLogger(LOGGER_NAME)
15
+
16
+
17
+ def inspect_ui(script: modules.scripts.Script, is_img2img: bool):
18
+ """Get metadata about accepted arguments by inspecting GUI. Needs Gradio Blocks context."""
19
+ elems = script.ui(is_img2img)
20
+
21
+ metadata = []
22
+ if not isinstance(elems, Sequence):
23
+ return metadata
24
+
25
+ for elem in elems:
26
+ data = {
27
+ "type": "None",
28
+ "label": elem.label,
29
+ "val": elem.value,
30
+ "is_index": False,
31
+ }
32
+ if isinstance(elem, gr.HTML):
33
+ data.update(val="")
34
+ elif isinstance(elem, gr.Markdown):
35
+ data.update(val="")
36
+ elif isinstance(elem, gr.Slider):
37
+ data.update(
38
+ type="range",
39
+ min=elem.minimum,
40
+ max=elem.maximum,
41
+ step=elem.step,
42
+ )
43
+ elif isinstance(elem, gr.Radio):
44
+ data.update(
45
+ type="combo",
46
+ is_index=elem.type == "index",
47
+ opts=elem.choices,
48
+ )
49
+ elif isinstance(elem, gr.Dropdown):
50
+ data.update(
51
+ type="combo",
52
+ is_index=elem.type == "index",
53
+ opts=elem.choices,
54
+ )
55
+ elif isinstance(elem, gr.Textbox):
56
+ data.update(
57
+ type="text",
58
+ )
59
+ elif isinstance(elem, gr.Checkbox):
60
+ data.update(
61
+ type="checkbox",
62
+ )
63
+ elif isinstance(elem, gr.CheckboxGroup):
64
+ data.update(
65
+ type="multiselect",
66
+ is_index=elem.type == "index",
67
+ opts=elem.choices,
68
+ )
69
+ elif isinstance(elem, gr.File):
70
+ data.update(val="") # unsupported
71
+ else:
72
+ data.update(val="") # unsupported
73
+ metadata.append(data)
74
+
75
+ return metadata
76
+
77
+
78
+ img2img_script_meta = None
79
+ txt2img_script_meta = None
80
+
81
+
82
+ def get_scripts_metadata(is_img2img: bool):
83
+ """Get metadata about accepted arguments for scripts."""
84
+ # NOTE: inspect_ui is quite slow, so cache this
85
+ global txt2img_script_meta, img2img_script_meta
86
+ if is_img2img:
87
+ runner = modules.scripts.scripts_img2img
88
+ else:
89
+ runner = modules.scripts.scripts_txt2img
90
+ metadata = {"None": []}
91
+ if (
92
+ is_img2img
93
+ and img2img_script_meta
94
+ and len(img2img_script_meta) - 1 == len(runner.titles)
95
+ ):
96
+ return img2img_script_meta
97
+ elif txt2img_script_meta and len(txt2img_script_meta) - 1 == len(runner.titles):
98
+ return txt2img_script_meta
99
+
100
+ with gr.Blocks(visible=False, analytics_enabled=False):
101
+ for name, script in zip(runner.titles, runner.selectable_scripts):
102
+ metadata[name] = inspect_ui(script, is_img2img)
103
+ if is_img2img:
104
+ img2img_script_meta = metadata
105
+ else:
106
+ txt2img_script_meta = metadata
107
+ return metadata
108
+
109
+
110
+ def get_script_info(
111
+ script_name: str, is_img2img: bool
112
+ ) -> Tuple[int, modules.scripts.Script, List[dict]]:
113
+ """Get index of script, script instance and argument metadata by name.
114
+
115
+ Args:
116
+ script_name (str): Exact name of script.
117
+ is_img2img (bool): Whether the script is for img2img or txt2img.
118
+
119
+ Raises:
120
+ KeyError: Script cannot be found.
121
+
122
+ Returns:
123
+ Tuple[int, Script, List[dict]]: Index of script, script itself and arguments metadata.
124
+ """
125
+ if is_img2img:
126
+ runner = modules.scripts.scripts_img2img
127
+ else:
128
+ runner = modules.scripts.scripts_txt2img
129
+ # in API, index 0 means no script, scripts are indexed from 1 onwards
130
+ names = ["None"] + runner.titles
131
+ if script_name == "None":
132
+ return 0, None, []
133
+ for i, n in enumerate(names):
134
+ if n == script_name:
135
+ script = runner.selectable_scripts[i - 1]
136
+ return i, script, get_scripts_metadata(is_img2img)[n]
137
+ raise KeyError(f"script not found for type {type}: {script_name}")
138
+
139
+
140
+ def process_script_args(
141
+ script_ind: int, script: modules.scripts.Script, meta: List[dict], args: list
142
+ ) -> list:
143
+ """Get the position arguments required."""
144
+ if script is None:
145
+ return [0] # 0th element selects which script to use. 0 is None.
146
+
147
+ # convert strings back to indexes
148
+ for i, (o, arg) in enumerate(zip(meta, args)):
149
+ if o["is_index"]:
150
+ if isinstance(arg, list):
151
+ args[i] = [o["opts"].index(v) for v in arg]
152
+ else:
153
+ args[i] = o["opts"].index(arg)
154
+
155
+ log.info(
156
+ f"Script selected: {script.filename}, Args Range: [{script.args_from}:{script.args_to}]"
157
+ )
158
+ # pad the args like the internal API requires...
159
+ args = [script_ind] + [0] * (script.args_from - 1) + args
160
+ log.info(f"Script args:\n{args}")
161
+ return args
backend/structs.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, List, Optional
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from .config import Img2ImgOptions, PluginOptions, Txt2ImgOptions, UpscaleOptions
8
+ from .utils import optional
9
+
10
+
11
+ @optional
12
+ class DefaultTxt2ImgOptions(Txt2ImgOptions):
13
+ pass
14
+
15
+
16
+ class Txt2ImgRequest(DefaultTxt2ImgOptions):
17
+ """Text2Img API request. If optional attributes aren't set, the defaults
18
+ from `krita_config.yaml` will be used.
19
+ """
20
+
21
+ orig_width: int
22
+ """Requested image width."""
23
+ orig_height: int
24
+ """Requested image height."""
25
+
26
+
27
+ @optional
28
+ class DefaultImg2ImgOptions(Img2ImgOptions):
29
+ pass
30
+
31
+
32
+ class Img2ImgRequest(DefaultImg2ImgOptions):
33
+ """Img2Img API request. If optional attributes aren't set, the defaults from
34
+ `krita_config.yaml` will be used.
35
+ """
36
+
37
+ src_img: str
38
+ """Image being used."""
39
+ mask_img: Optional[str] = None
40
+ """Image mask being used."""
41
+
42
+
43
+ @optional
44
+ class DefaultUpscaleOptions(UpscaleOptions):
45
+ pass
46
+
47
+
48
+ class UpscaleRequest(DefaultUpscaleOptions):
49
+ """Upscale API request. If optional attributes aren't set, the defaults from
50
+ `krita_config.yaml` will be used.
51
+ """
52
+
53
+ src_img: str
54
+ """Image being used."""
55
+
56
+
57
+ class ConfigResponse(PluginOptions):
58
+ sample_path: str
59
+ """Where the Krita plugin should save the selected region and mask."""
60
+ upscalers: List[str]
61
+ """List of available upscalers."""
62
+ samplers: List[str]
63
+ """List of available samplers."""
64
+ samplers_img2img: List[str]
65
+ """List of available samplers specifically for img2img (upstream separated them for a reason)."""
66
+ scripts_txt2img: Dict[str, List[Dict]]
67
+ """List of available txt2img scripts."""
68
+ scripts_img2img: Dict[str, List[Dict]]
69
+ """List of available img2img scripts."""
70
+ face_restorers: List[str]
71
+ """List of available face restorers."""
72
+ sd_models: List[str]
73
+ """List of available models."""
74
+
75
+
76
+ class ImageResponse(BaseModel):
77
+ outputs: List[str]
78
+ """List of generated images encoded in base64."""
79
+ info: str
80
+ """Generation info already jsonified."""
81
+
82
+
83
+ class UpscaleResponse(BaseModel):
84
+ output: str
85
+ """Upscaled image in base64."""
backend/utils.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import logging
5
+ import os
6
+ import secrets
7
+ from base64 import b64decode, b64encode
8
+ from io import BytesIO
9
+ from itertools import cycle
10
+ from math import ceil
11
+
12
+ import modules
13
+ import yaml
14
+ from modules import shared
15
+ from PIL import Image
16
+ from pydantic import BaseModel
17
+
18
+ from .config import CONFIG_PATH, ENCRYPT_FILE, LOGGER_NAME, MainConfig
19
+
20
+ log = logging.getLogger(LOGGER_NAME)
21
+
22
+
23
+ def load_config():
24
+ """Load default config (including those not exposed in the API yet) from
25
+ `CONFIG_PATH` in the current working directory.
26
+
27
+ Will create `CONFIG_PATH` if it has yet to exist using `MainConfig` from
28
+ `config.py`.
29
+
30
+ Returns:
31
+ MainConfig: config
32
+ """
33
+ if not os.path.isfile(CONFIG_PATH):
34
+ cfg = MainConfig()
35
+ with open(CONFIG_PATH, "w") as f:
36
+ yaml.safe_dump(cfg.dict(), f)
37
+
38
+ with open(CONFIG_PATH) as file:
39
+ obj = yaml.safe_load(file)
40
+ return MainConfig.parse_obj(obj)
41
+
42
+
43
+ def merge_default_config(config: BaseModel, default: BaseModel):
44
+ """Replace unset and None fields in opt with values from default with the
45
+ same field name in place.
46
+
47
+ Unset fields does not include fields that are explicitly set to None but
48
+ includes fields with a default value due to being unset.
49
+
50
+ Args:
51
+ config (BaseModel): Config object.
52
+ default (BaseModel): Default to merge from.
53
+
54
+ Returns:
55
+ BaseModel: Modified config.
56
+ """
57
+
58
+ for field in config.__fields__:
59
+ if not field in config.__fields_set__ or field is None:
60
+ setattr(config, field, getattr(default, field, None))
61
+
62
+ return config
63
+
64
+
65
+ def prepare_backend(opt: BaseModel):
66
+ """Misc configuration and preparation tasks before calling internal API.
67
+
68
+ Currently includes:
69
+ - Ensuring the output/input folders exist
70
+ - Set the global face restorer model to the selected one
71
+ - Set the global SD model to the selected one
72
+ - Set the global upscaler to the selected one
73
+ - Set other misc global webUI/backend settings
74
+
75
+ Args:
76
+ opt (BaseModel): Option/Request object
77
+ """
78
+ # the `shared` module handles app state for the underlying codebase
79
+
80
+ if hasattr(opt, "face_restorer"):
81
+ shared.opts.face_restoration_model = opt.face_restorer
82
+ shared.opts.code_former_weight = opt.codeformer_weight
83
+
84
+ if hasattr(opt, "sd_model"):
85
+ shared.opts.sd_model_checkpoint = opt.sd_model
86
+ modules.sd_models.reload_model_weights(shared.sd_model)
87
+
88
+ if hasattr(opt, "upscaler_name"):
89
+ shared.opts.upscaler_for_img2img = opt.upscaler_name
90
+
91
+ if hasattr(opt, "color_correct"):
92
+ shared.opts.img2img_color_correction = opt.color_correct
93
+ shared.opts.img2img_fix_steps = opt.do_exact_steps
94
+
95
+ if hasattr(opt, "filter_nsfw"):
96
+ shared.opts.filter_nsfw = opt.filter_nsfw
97
+
98
+ if hasattr(opt, "inpaint_mask_weight"):
99
+ shared.opts.inpainting_mask_weight = opt.inpaint_mask_weight
100
+
101
+ # Ensure the output/input folders exist
102
+ if hasattr(opt, "sample_path"):
103
+ os.makedirs(opt.sample_path, exist_ok=True)
104
+
105
+
106
+ def optional(*fields):
107
+ """Decorator function used to modify a pydantic model's fields to all be optional.
108
+ Alternatively, you can also pass the field names that should be made optional as arguments
109
+ to the decorator.
110
+ Taken from https://github.com/samuelcolvin/pydantic/issues/1223#issuecomment-775363074
111
+ """
112
+
113
+ def dec(_cls):
114
+ for field in fields:
115
+ _cls.__fields__[field].required = False
116
+ return _cls
117
+
118
+ if fields and inspect.isclass(fields[0]) and issubclass(fields[0], BaseModel):
119
+ cls = fields[0]
120
+ fields = cls.__fields__
121
+ return dec(cls)
122
+
123
+ return dec
124
+
125
+
126
+ def save_img(image: Image.Image, sample_path: str, filename: str):
127
+ """Saves an image.
128
+
129
+ Args:
130
+ image (Image): Image to save.
131
+ sample_path (str): Folder to save the image in.
132
+ filename (str): Name to save the image as.
133
+
134
+ Returns:
135
+ str: Absolute path where the image was saved.
136
+ """
137
+ path = os.path.join(sample_path, filename)
138
+ image.save(path)
139
+ return os.path.abspath(path)
140
+
141
+
142
+ def img_to_b64(image: Image.Image):
143
+ """Convert an image to base64-encoded string.
144
+
145
+ Args:
146
+ image (Image): Image to encode.
147
+
148
+ Returns:
149
+ str: Base64-encoded image.
150
+ """
151
+ buf = BytesIO()
152
+ image.save(buf, format="png")
153
+ return b64encode(buf.getvalue()).decode("utf-8")
154
+
155
+
156
+ def b64_to_img(enc: str):
157
+ """Convert base64-encoded string to image.
158
+
159
+ Args:
160
+ enc (str): Base64-encoded image.
161
+
162
+ Returns:
163
+ Image: Image.
164
+ """
165
+ return Image.open(BytesIO(b64decode(enc)))
166
+
167
+
168
+ def sddebz_highres_fix(
169
+ base_size: int, max_size: int, orig_width: int, orig_height: int
170
+ ):
171
+ """Calculate an appropiate image resolution given the base input size of the
172
+ model and max input size allowed.
173
+
174
+ The max input size is due to how Stable Diffusion currently handles resolutions
175
+ larger than its base/native input size of 512, which can cause weird issues
176
+ such as duplicated features in the image. Hence, it is typically better to
177
+ render at a smaller appropiate resolution before using other methods to upscale
178
+ to the original resolution. Setting max_size to 512, matching the base_size,
179
+ imitates how the highres fix works.
180
+
181
+ Stable Diffusion also messes up for resolutions smaller than 512. In which case,
182
+ it is better to render at the base resolution before downscaling to the original.
183
+
184
+ This method requires less user input than the builtin highres fix, which uses
185
+ firstphase_width and firstphase_height.
186
+
187
+ The original plugin writer, @sddebz, wrote this. I modified it to `ceil`
188
+ instead of `round` to make selected region resizing easier in the plugin, and
189
+ to avoid rounding to 0.
190
+
191
+ Args:
192
+ base_size (int): Native/base input size of the model.
193
+ max_size (int): Max input size to accept.
194
+ orig_width (int): Original width requested.
195
+ orig_height (int): Original height requested.
196
+
197
+ Returns:
198
+ Tuple[int, int]: Appropiate (width, height) to use for the model.
199
+ """
200
+
201
+ def rnd(r, x, z=64):
202
+ """Scale dimension x with stride z while attempting to preserve aspect ratio r."""
203
+ return z * ceil(r * x / z)
204
+
205
+ ratio = orig_width / orig_height
206
+
207
+ # height is smaller dimension
208
+ if orig_width > orig_height:
209
+ width, height = rnd(ratio, base_size), base_size
210
+ if width > max_size:
211
+ width, height = max_size, rnd(1 / ratio, max_size)
212
+ # width is smaller dimension
213
+ else:
214
+ width, height = base_size, rnd(1 / ratio, base_size)
215
+ if height > max_size:
216
+ width, height = rnd(ratio, max_size), max_size
217
+
218
+ new_ratio = width / height
219
+
220
+ log.info(
221
+ f"img size: {orig_width}x{orig_height} -> {width}x{height}, "
222
+ f"aspect ratio: {ratio:.2f} -> {new_ratio:.2f}, {100 * (new_ratio - ratio) / ratio :.2f}% change"
223
+ )
224
+ return width, height
225
+
226
+
227
+ def parse_prompt(val):
228
+ """Parse different representations of prompt/negative prompt.
229
+
230
+ Args:
231
+ val (Any): Prompt to parse.
232
+
233
+ Raises:
234
+ SyntaxError: Value of the prompt key cannot be parsed.
235
+
236
+ Returns:
237
+ str: Correctly formatted prompt.
238
+ """
239
+ if val is None:
240
+ return ""
241
+ # Below cases are meant for prompts read from the yaml config
242
+ if isinstance(val, str):
243
+ return val
244
+ if isinstance(val, list):
245
+ return ", ".join(val)
246
+ if isinstance(val, dict):
247
+ prompt = ""
248
+ for item, weight in val.items():
249
+ if not prompt == "":
250
+ prompt += " "
251
+ if weight is None:
252
+ prompt += f"{item}"
253
+ else:
254
+ prompt += f"({item}:{weight})"
255
+ return prompt
256
+ raise SyntaxError(f"prompt field in {CONFIG_PATH} is invalid")
257
+
258
+
259
+ def get_sampler_index(sampler_name: str):
260
+ """Get index of sampler by name.
261
+
262
+ Args:
263
+ sampler_name (str): Exact name of sampler.
264
+
265
+ Raises:
266
+ KeyError: Sampler cannot be found.
267
+
268
+ Returns:
269
+ int: Index of sampler.
270
+ """
271
+ for index, sampler in enumerate(modules.sd_samplers.samplers):
272
+ if sampler_name == sampler.name or sampler_name in sampler.aliases:
273
+ return index
274
+ raise KeyError(f"sampler not found: {sampler_name}")
275
+
276
+
277
+ def get_upscaler_index(upscaler_name: str):
278
+ """Get index of upscaler by name.
279
+
280
+ Args:
281
+ upscaler_name (str): Exact name of upscaler.
282
+
283
+ Raises:
284
+ KeyError: Upscaler cannot be found.
285
+
286
+ Returns:
287
+ int: Index of sampler.
288
+ """
289
+ for index, upscaler in enumerate(shared.sd_upscalers):
290
+ if upscaler.name == upscaler_name:
291
+ return index
292
+ raise KeyError(f"upscaler not found: {upscaler_name}")
293
+
294
+
295
+ def prepare_mask(mask: Image.Image):
296
+ """Prepare mask for usage.
297
+
298
+ Args:
299
+ mask (Image): mask.
300
+
301
+ Returns:
302
+ Image: The luminance mask.
303
+ """
304
+ return mask.getchannel("A")
305
+
306
+
307
+ def bytewise_xor(msg: bytes, key: bytes):
308
+ """Used for decrypting/encrypting request/response bodies."""
309
+ return bytes(v ^ k for v, k in zip(msg, cycle(key)))
310
+
311
+
312
+ def get_encrypt_key():
313
+ """Read encryption key from file."""
314
+ try:
315
+ with open(ENCRYPT_FILE) as f:
316
+ return f.read().strip().encode("utf-8")
317
+ except:
318
+ if not os.path.exists(ENCRYPT_FILE):
319
+ log.warning(
320
+ f"Encryption key file doesn't exist at {os.path.abspath(ENCRYPT_FILE)}."
321
+ )
322
+ log.warning(f"Creating random encryption key.")
323
+ with open(ENCRYPT_FILE, "w") as f:
324
+ f.write(secrets.token_hex(16))
325
+ log.warning(
326
+ f"Key in {ENCRYPT_FILE} is completely optional. It can be used to encrypt messages between backend & Krita and is editable."
327
+ )
328
+ return get_encrypt_key()
329
+ return None
frontends/krita/krita_diff.desktop ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ [Desktop Entry]
2
+ Type=Service
3
+ ServiceTypes=Krita/PythonPlugin
4
+ X-KDE-Library=krita_diff
5
+ X-Python-2-Compatible=false
6
+ X-Krita-Manual=manual.html
7
+ Name=Stable Diffusion Plugin
8
+ Comment=Expose the power of AUTOMATIC1111's Stable Diffusion fork for creating to your heart's desire.
frontends/krita/krita_diff/__init__.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from krita import DockWidgetFactory, DockWidgetFactoryBase, Krita
2
+
3
+ from .defaults import (
4
+ TAB_CONFIG,
5
+ TAB_IMG2IMG,
6
+ TAB_INPAINT,
7
+ TAB_SDCOMMON,
8
+ TAB_TXT2IMG,
9
+ TAB_UPSCALE,
10
+ )
11
+ from .docker import create_docker
12
+ from .extension import SDPluginExtension
13
+ from .pages import (
14
+ ConfigPage,
15
+ Img2ImgPage,
16
+ InpaintPage,
17
+ SDCommonPage,
18
+ Txt2ImgPage,
19
+ UpscalePage,
20
+ )
21
+ from .script import script
22
+ from .utils import reset_docker_layout
23
+
24
+ instance = Krita.instance()
25
+ instance.addExtension(SDPluginExtension(instance))
26
+ instance.addDockWidgetFactory(
27
+ DockWidgetFactory(
28
+ TAB_SDCOMMON,
29
+ DockWidgetFactoryBase.DockLeft,
30
+ create_docker(SDCommonPage),
31
+ )
32
+ )
33
+ instance.addDockWidgetFactory(
34
+ DockWidgetFactory(
35
+ TAB_TXT2IMG,
36
+ DockWidgetFactoryBase.DockLeft,
37
+ create_docker(Txt2ImgPage),
38
+ )
39
+ )
40
+ instance.addDockWidgetFactory(
41
+ DockWidgetFactory(
42
+ TAB_IMG2IMG,
43
+ DockWidgetFactoryBase.DockLeft,
44
+ create_docker(Img2ImgPage),
45
+ )
46
+ )
47
+ instance.addDockWidgetFactory(
48
+ DockWidgetFactory(
49
+ TAB_INPAINT,
50
+ DockWidgetFactoryBase.DockLeft,
51
+ create_docker(InpaintPage),
52
+ )
53
+ )
54
+ instance.addDockWidgetFactory(
55
+ DockWidgetFactory(
56
+ TAB_UPSCALE,
57
+ DockWidgetFactoryBase.DockLeft,
58
+ create_docker(UpscalePage),
59
+ )
60
+ )
61
+ instance.addDockWidgetFactory(
62
+ DockWidgetFactory(
63
+ TAB_CONFIG,
64
+ DockWidgetFactoryBase.DockLeft,
65
+ create_docker(ConfigPage),
66
+ )
67
+ )
68
+
69
+
70
+ # dumb workaround to ensure its only created once
71
+ if script.cfg("first_setup", bool):
72
+ instance.notifier().windowCreated.connect(reset_docker_layout)
73
+ script.cfg.set("first_setup", False)
frontends/krita/krita_diff/client.py ADDED
@@ -0,0 +1,402 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import socket
3
+ from typing import Any, Dict, List
4
+ from urllib.error import URLError
5
+ from urllib.parse import urljoin, urlparse
6
+ from urllib.request import Request, urlopen
7
+
8
+ from krita import QObject, QThread, pyqtSignal
9
+
10
+ from .config import Config
11
+ from .defaults import (
12
+ ERR_BAD_URL,
13
+ ERR_NO_CONNECTION,
14
+ LONG_TIMEOUT,
15
+ OFFICIAL_ROUTE_PREFIX,
16
+ ROUTE_PREFIX,
17
+ SHORT_TIMEOUT,
18
+ STATE_DONE,
19
+ STATE_READY,
20
+ STATE_URLERROR,
21
+ THREADED,
22
+ )
23
+ from .utils import bytewise_xor, fix_prompt, get_ext_args, get_ext_key, img_to_b64
24
+
25
+ # NOTE: backend queues up responses, so no explicit need to block multiple requests
26
+ # except to prevent user from spamming themselves
27
+
28
+ # TODO: tab showing all queued up requests (local plugin instance only)
29
+
30
+
31
+ def get_url(cfg: Config, route: str = ..., prefix: str = ROUTE_PREFIX):
32
+ base = cfg("base_url", str)
33
+ if not urlparse(base).scheme in {"http", "https"}:
34
+ return None
35
+ url = urljoin(base, prefix)
36
+ if route is not ...:
37
+ url = urljoin(url, route)
38
+ # print("url:", url)
39
+ return url
40
+
41
+
42
+ # krita doesn't reexport QtNetwork
43
+ class AsyncRequest(QObject):
44
+ timeout = None
45
+ finished = pyqtSignal()
46
+ result = pyqtSignal(object)
47
+ error = pyqtSignal(Exception)
48
+
49
+ def __init__(
50
+ self,
51
+ url: str,
52
+ data: Any = None,
53
+ timeout: int = ...,
54
+ method: str = ...,
55
+ headers: dict = ...,
56
+ key: str = None,
57
+ ):
58
+ """Create an AsyncRequest object.
59
+
60
+ By default, AsyncRequest has no timeout, will infer whether it is "POST"
61
+ or "GET" based on the presence of `data` and uses JSON to transmit. It
62
+ also assumes the response is JSON.
63
+
64
+ Args:
65
+ url (str): URL to request from.
66
+ data (Any, optional): Payload to send. Defaults to None.
67
+ timeout (int, optional): Timeout for request. Defaults to `...`.
68
+ method (str, optional): Which HTTP method to use. Defaults to `...`.
69
+ key (Union[str, None], Optional): Key to use for encryption/decryption. Defaults to None.
70
+ """
71
+ super(AsyncRequest, self).__init__()
72
+ self.url = url
73
+ self.data = None if data is None else json.dumps(data).encode("utf-8")
74
+ self.headers = {} if headers is ... else headers
75
+
76
+ self.key = None
77
+ if isinstance(key, str) and key.strip() != "":
78
+ self.key = key.strip().encode("utf-8")
79
+
80
+ if self.key is not None:
81
+ self.headers["X-Encrypted-Body"] = "XOR"
82
+ if timeout is not ...:
83
+ self.timeout = timeout
84
+ if method is ...:
85
+ self.method = "GET" if data is None else "POST"
86
+ else:
87
+ self.method = method
88
+ if self.data is not None:
89
+ if self.key is not None:
90
+ # print(f"Encrypting with ${self.key}:\n{self.data}")
91
+ self.data = bytewise_xor(self.data, self.key)
92
+ # print(f"Encrypt Result:\n{self.data}")
93
+ self.headers["Content-Type"] = "application/json"
94
+ self.headers["Content-Length"] = str(len(self.data))
95
+
96
+ def run(self):
97
+ req = Request(self.url, headers=self.headers, method=self.method)
98
+ try:
99
+ with urlopen(req, self.data, self.timeout) as res:
100
+ data = res.read()
101
+ enc_type = res.getheader("X-Encrypted-Body", None)
102
+ assert enc_type in {"XOR", None}, "Unknown server encryption!"
103
+ if enc_type == "XOR":
104
+ assert self.key, f"Key needed to decrypt server response!"
105
+ # print(f"Decrypting with ${self.key}:\n{data}")
106
+ data = bytewise_xor(data, self.key)
107
+ # print(f"Decrypt Result:\n{data}")
108
+ self.result.emit(json.loads(data))
109
+ except Exception as e:
110
+ self.error.emit(e)
111
+ finally:
112
+ self.finished.emit()
113
+
114
+ @classmethod
115
+ def request(cls, *args, **kwargs):
116
+ req = cls(*args, **kwargs)
117
+ if THREADED:
118
+ thread = QThread()
119
+ # NOTE: need to keep reference to thread or it gets destroyed
120
+ req.thread = thread
121
+ req.moveToThread(thread)
122
+ thread.started.connect(req.run)
123
+ req.finished.connect(thread.quit)
124
+ # NOTE: is this a memory leak?
125
+ # For some reason, deleteLater occurs while thread is still running, resulting in crash
126
+ # req.finished.connect(req.deleteLater)
127
+ # thread.finished.connect(thread.deleteLater)
128
+ return req, lambda: thread.start()
129
+ else:
130
+ return req, lambda: req.run()
131
+
132
+
133
+ class Client(QObject):
134
+ status = pyqtSignal(str)
135
+ config_updated = pyqtSignal()
136
+
137
+ def __init__(self, cfg: Config, ext_cfg: Config):
138
+ """It is highly dependent on config's structure to the point it writes directly to it. :/"""
139
+ super(Client, self).__init__()
140
+ self.cfg = cfg
141
+ self.ext_cfg = ext_cfg
142
+ self.short_reqs = set()
143
+ self.long_reqs = set()
144
+ # NOTE: this is a hacky workaround for detecting if backend is reachable
145
+ self.is_connected = False
146
+
147
+ def handle_api_error(self, exc: Exception):
148
+ """Handle exceptions that can occur while interacting with the backend."""
149
+ self.is_connected = False
150
+ try:
151
+ # wtf python? socket raises an error that isnt an Exception??
152
+ if isinstance(exc, socket.timeout):
153
+ raise TimeoutError
154
+ else:
155
+ raise exc
156
+ except URLError as e:
157
+ self.status.emit(f"{STATE_URLERROR}: {e.reason}")
158
+ except TimeoutError as e:
159
+ self.status.emit(f"{STATE_URLERROR}: response timed out")
160
+ except json.JSONDecodeError as e:
161
+ self.status.emit(f"{STATE_URLERROR}: invalid JSON response")
162
+ except ValueError as e:
163
+ self.status.emit(f"{STATE_URLERROR}: Invalid backend URL")
164
+ except ConnectionError as e:
165
+ self.status.emit(f"{STATE_URLERROR}: connection error during request")
166
+ except Exception as e:
167
+ # self.status.emit(f"{STATE_URLERROR}: Unexpected Error")
168
+ # self.status.emit(str(e))
169
+ assert False, e
170
+
171
+ def post(
172
+ self, route, body, cb, base_url=..., is_long=True, ignore_no_connection=False
173
+ ):
174
+ if not ignore_no_connection and not self.is_connected:
175
+ self.status.emit(ERR_NO_CONNECTION)
176
+ return
177
+ url = get_url(self.cfg, route) if base_url is ... else urljoin(base_url, route)
178
+ if not url:
179
+ self.status.emit(ERR_BAD_URL)
180
+ return
181
+ # TODO: how to cancel this? destroy the thread after sending API interrupt request?
182
+ req, start = AsyncRequest.request(
183
+ url,
184
+ body,
185
+ LONG_TIMEOUT if is_long else SHORT_TIMEOUT,
186
+ key=self.cfg("encryption_key"),
187
+ )
188
+
189
+ if is_long:
190
+ self.long_reqs.add(req)
191
+ else:
192
+ self.short_reqs.add(req)
193
+
194
+ def handler():
195
+ self.long_reqs.discard(req)
196
+ self.short_reqs.discard(req)
197
+ if is_long and len(self.long_reqs) == 0:
198
+ self.status.emit(STATE_DONE)
199
+
200
+ req.result.connect(cb)
201
+ req.error.connect(lambda e: self.handle_api_error(e))
202
+ req.finished.connect(handler)
203
+ start()
204
+
205
+ def get(self, route, cb, base_url=..., is_long=False, ignore_no_connection=False):
206
+ self.post(
207
+ route,
208
+ None,
209
+ cb,
210
+ base_url=base_url,
211
+ is_long=is_long,
212
+ ignore_no_connection=ignore_no_connection,
213
+ )
214
+
215
+ def common_params(self, has_selection):
216
+ """Parameters nearly all the post routes share."""
217
+ tiling = self.cfg("sd_tiling", bool) and not (
218
+ self.cfg("only_full_img_tiling", bool) and has_selection
219
+ )
220
+
221
+ # its fine to stuff extra stuff here; pydantic will shave off irrelevant params
222
+ params = dict(
223
+ sd_model=self.cfg("sd_model", str),
224
+ batch_count=self.cfg("sd_batch_count", int),
225
+ batch_size=self.cfg("sd_batch_size", int),
226
+ base_size=self.cfg("sd_base_size", int),
227
+ max_size=self.cfg("sd_max_size", int),
228
+ tiling=tiling,
229
+ upscaler_name=self.cfg("upscaler_name", str),
230
+ restore_faces=self.cfg("face_restorer_model", str) != "None",
231
+ face_restorer=self.cfg("face_restorer_model", str),
232
+ codeformer_weight=self.cfg("codeformer_weight", float),
233
+ filter_nsfw=self.cfg("filter_nsfw", bool),
234
+ do_exact_steps=self.cfg("do_exact_steps", bool),
235
+ include_grid=self.cfg("include_grid", bool),
236
+ save_samples=self.cfg("save_temp_images", bool),
237
+ )
238
+ return params
239
+
240
+ def get_config(self):
241
+ def cb(obj):
242
+ try:
243
+ assert "sample_path" in obj
244
+ assert len(obj["upscalers"]) > 0
245
+ assert len(obj["samplers"]) > 0
246
+ assert len(obj["samplers_img2img"]) > 0
247
+ assert len(obj["face_restorers"]) > 0
248
+ assert len(obj["sd_models"]) > 0
249
+ assert len(obj["scripts_txt2img"]) > 0
250
+ assert len(obj["scripts_img2img"]) > 0
251
+ except:
252
+ self.status.emit(
253
+ f"{STATE_URLERROR}: incompatible response, are you running the right API?"
254
+ )
255
+ print("Invalid Response:\n", obj)
256
+ return
257
+
258
+ # replace only after verifying
259
+ self.cfg.set("sample_path", obj["sample_path"])
260
+ # NOTE: sorting these lists is risky; ivent 100% verified that I removed all reliance on indexes
261
+ self.cfg.set("upscaler_list", obj["upscalers"])
262
+ self.cfg.set("txt2img_sampler_list", obj["samplers"])
263
+ self.cfg.set("img2img_sampler_list", obj["samplers_img2img"])
264
+ self.cfg.set("inpaint_sampler_list", obj["samplers_img2img"])
265
+ self.cfg.set("txt2img_script_list", list(obj["scripts_txt2img"].keys()))
266
+ self.cfg.set("img2img_script_list", list(obj["scripts_img2img"].keys()))
267
+ self.cfg.set("inpaint_script_list", list(obj["scripts_img2img"].keys()))
268
+ self.cfg.set("face_restorer_model_list", obj["face_restorers"])
269
+ self.cfg.set("sd_model_list", obj["sd_models"])
270
+
271
+ # extension script cfg
272
+ obj["scripts_inpaint"] = obj["scripts_img2img"]
273
+ for ext_type in {"scripts_txt2img", "scripts_img2img", "scripts_inpaint"}:
274
+ metadata: Dict[str, List[dict]] = obj[ext_type]
275
+ self.ext_cfg.set(f"{ext_type}_len", len(metadata))
276
+ for ext_name, ext_meta in metadata.items():
277
+ old_val = self.ext_cfg(get_ext_key(ext_type, ext_name))
278
+ new_val = json.dumps(ext_meta)
279
+ if new_val != old_val:
280
+ self.ext_cfg.set(get_ext_key(ext_type, ext_name), new_val)
281
+ for i, opt in enumerate(ext_meta):
282
+ key = get_ext_key(ext_type, ext_name, i)
283
+ self.ext_cfg.set(key, opt["val"])
284
+
285
+ self.is_connected = True
286
+ self.status.emit(STATE_READY)
287
+ self.config_updated.emit()
288
+
289
+ self.get("config", cb, ignore_no_connection=True)
290
+
291
+ def post_txt2img(self, cb, width, height, has_selection):
292
+ params = dict(orig_width=width, orig_height=height)
293
+ if not self.cfg("just_use_yaml", bool):
294
+ seed = (
295
+ int(self.cfg("txt2img_seed", str)) # Qt casts int as 32-bit int
296
+ if not self.cfg("txt2img_seed", str).strip() == ""
297
+ else -1
298
+ )
299
+ ext_name = self.cfg("txt2img_script", str)
300
+ ext_args = get_ext_args(self.ext_cfg, "scripts_txt2img", ext_name)
301
+ params.update(self.common_params(has_selection))
302
+ params.update(
303
+ prompt=fix_prompt(self.cfg("txt2img_prompt", str)),
304
+ negative_prompt=fix_prompt(self.cfg("txt2img_negative_prompt", str)),
305
+ sampler_name=self.cfg("txt2img_sampler", str),
306
+ steps=self.cfg("txt2img_steps", int),
307
+ cfg_scale=self.cfg("txt2img_cfg_scale", float),
308
+ seed=seed,
309
+ highres_fix=self.cfg("txt2img_highres", bool),
310
+ denoising_strength=self.cfg("txt2img_denoising_strength", float),
311
+ script=ext_name,
312
+ script_args=ext_args,
313
+ )
314
+
315
+ self.post("txt2img", params, cb)
316
+
317
+ def post_img2img(self, cb, src_img, mask_img, has_selection):
318
+ params = dict(mode=0, src_img=img_to_b64(src_img))
319
+ if not self.cfg("just_use_yaml", bool):
320
+ seed = (
321
+ int(self.cfg("img2img_seed", str)) # Qt casts int as 32-bit int
322
+ if not self.cfg("img2img_seed", str).strip() == ""
323
+ else -1
324
+ )
325
+ ext_name = self.cfg("img2img_script", str)
326
+ ext_args = get_ext_args(self.ext_cfg, "scripts_img2img", ext_name)
327
+ params.update(self.common_params(has_selection))
328
+ params.update(
329
+ prompt=fix_prompt(self.cfg("img2img_prompt", str)),
330
+ negative_prompt=fix_prompt(self.cfg("img2img_negative_prompt", str)),
331
+ sampler_name=self.cfg("img2img_sampler", str),
332
+ steps=self.cfg("img2img_steps", int),
333
+ cfg_scale=self.cfg("img2img_cfg_scale", float),
334
+ denoising_strength=self.cfg("img2img_denoising_strength", float),
335
+ color_correct=self.cfg("img2img_color_correct", bool),
336
+ script=ext_name,
337
+ script_args=ext_args,
338
+ seed=seed,
339
+ )
340
+
341
+ self.post("img2img", params, cb)
342
+
343
+ def post_inpaint(self, cb, src_img, mask_img, has_selection):
344
+ assert mask_img, "Inpaint layer is needed for inpainting!"
345
+ params = dict(
346
+ mode=1, src_img=img_to_b64(src_img), mask_img=img_to_b64(mask_img)
347
+ )
348
+ if not self.cfg("just_use_yaml", bool):
349
+ seed = (
350
+ int(self.cfg("inpaint_seed", str)) # Qt casts int as 32-bit int
351
+ if not self.cfg("inpaint_seed", str).strip() == ""
352
+ else -1
353
+ )
354
+ fill = self.cfg("inpaint_fill_list", "QStringList").index(
355
+ self.cfg("inpaint_fill", str)
356
+ )
357
+ ext_name = self.cfg("inpaint_script", str)
358
+ ext_args = get_ext_args(self.ext_cfg, "scripts_inpaint", ext_name)
359
+ params.update(self.common_params(has_selection))
360
+ params.update(
361
+ prompt=fix_prompt(self.cfg("inpaint_prompt", str)),
362
+ negative_prompt=fix_prompt(self.cfg("inpaint_negative_prompt", str)),
363
+ sampler_name=self.cfg("inpaint_sampler", str),
364
+ steps=self.cfg("inpaint_steps", int),
365
+ cfg_scale=self.cfg("inpaint_cfg_scale", float),
366
+ denoising_strength=self.cfg("inpaint_denoising_strength", float),
367
+ color_correct=self.cfg("inpaint_color_correct", bool),
368
+ script=ext_name,
369
+ script_args=ext_args,
370
+ seed=seed,
371
+ invert_mask=self.cfg("inpaint_invert_mask", bool),
372
+ # mask_blur=self.cfg("inpaint_mask_blur", int),
373
+ inpainting_fill=fill,
374
+ # inpaint_full_res=self.cfg("inpaint_full_res", bool),
375
+ # inpaint_full_res_padding=self.cfg("inpaint_full_res_padding", int),
376
+ inpaint_mask_weight=self.cfg("inpaint_mask_weight", float),
377
+ include_grid=False, # it is never useful for inpaint mode
378
+ )
379
+
380
+ self.post("img2img", params, cb)
381
+
382
+ def post_upscale(self, cb, src_img):
383
+ params = (
384
+ {
385
+ "src_img": img_to_b64(src_img),
386
+ "upscaler_name": self.cfg("upscale_upscaler_name", str),
387
+ "downscale_first": self.cfg("upscale_downscale_first", bool),
388
+ }
389
+ if not self.cfg("just_use_yaml", bool)
390
+ else {"src_img": img_to_b64(src_img)}
391
+ )
392
+ self.post("upscale", params, cb)
393
+
394
+ def post_interrupt(self, cb):
395
+ # get official API url
396
+ url = get_url(self.cfg, prefix=OFFICIAL_ROUTE_PREFIX)
397
+ self.post("interrupt", {}, cb, base_url=url)
398
+
399
+ def get_progress(self, cb):
400
+ # get official API url
401
+ url = get_url(self.cfg, prefix=OFFICIAL_ROUTE_PREFIX)
402
+ self.get("progress", cb, base_url=url)
frontends/krita/krita_diff/config.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import asdict
2
+ from typing import Any
3
+
4
+ from krita import QObject, QReadWriteLock, QSettings
5
+
6
+ from .defaults import CFG_FOLDER, CFG_NAME, DEFAULTS, ERR_MISSING_CONFIG
7
+
8
+
9
+ class Config(QObject):
10
+ def __init__(self, folder=CFG_FOLDER, name=CFG_NAME, model=DEFAULTS):
11
+ """Sorta like a controller for QSettings.
12
+
13
+ I'm going to treat this as a singleton global app state, but implemented
14
+ correctly such that it should be theoretically possible to have multiple
15
+ instances (maybe multiple dockers controlling multiple remotes?)
16
+
17
+ If model is None, Config will not check if keys exist.
18
+
19
+ Args:
20
+ folder (str, optional): Which folder to store settings in. Defaults to CFG_FOLDER.
21
+ name (str, optional): Name of settings file. Defaults to CFG_NAME.
22
+ model (Any, optional): Data model representing config & defaults. Defaults to DEFAULTS.
23
+ """
24
+ # See: https://doc.qt.io/qt-6/qsettings.html#accessing-settings-from-multiple-threads-or-processes-simultaneously
25
+ # but im too lazy to figure out creating separate QSettings per worker, so we will just lock
26
+ super(Config, self).__init__()
27
+ self.model = model # is immutable
28
+ self.lock = QReadWriteLock()
29
+ self.config = QSettings(QSettings.IniFormat, QSettings.UserScope, folder, name)
30
+
31
+ # add in new config settings
32
+ self.restore_defaults(overwrite=False)
33
+
34
+ def __call__(self, key: str, type: type = str):
35
+ """Shorthand for Config.get()"""
36
+ return self.get(key, type)
37
+
38
+ def get(self, key: str, type: type = str):
39
+ """Get config value by key & cast to type.
40
+
41
+ Args:
42
+ key (str): Name of config option.
43
+ type (type, optional): Type to cast config value to. Defaults to str.
44
+
45
+ Returns:
46
+ Any: Config value.
47
+ """
48
+ self.lock.lockForRead()
49
+ try:
50
+ # notably QSettings assume strings too unless specified
51
+ if self.model is not None:
52
+ assert self.config.contains(key) and hasattr(
53
+ self.model, key
54
+ ), ERR_MISSING_CONFIG
55
+ val = self.config.value(key, type=type)
56
+ return val
57
+ finally:
58
+ self.lock.unlock()
59
+
60
+ def set(self, key: str, val: Any, overwrite: bool = True):
61
+ """Set config value by key.
62
+
63
+ Args:
64
+ key (str): Name of config option.
65
+ val (Any): Config value.
66
+ overwrite (bool, optional): Whether to overwrite an existing value. Defaults to False.
67
+ """
68
+ self.lock.lockForWrite()
69
+ try:
70
+ if self.model is not None:
71
+ assert hasattr(self.model, key), ERR_MISSING_CONFIG
72
+ if overwrite or not self.config.contains(key):
73
+ self.config.setValue(key, val)
74
+ finally:
75
+ self.lock.unlock()
76
+
77
+ def restore_defaults(self, overwrite: bool = True):
78
+ """Reset settings to default.
79
+
80
+ Args:
81
+ overwrite (bool, optional): Whether to overwrite existing settings, else add only new ones. Defaults to True.
82
+ """
83
+ if self.model is None:
84
+ return
85
+ defaults = asdict(self.model)
86
+ for k, v in defaults.items():
87
+ self.set(k, v, overwrite)
frontends/krita/krita_diff/defaults.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ from typing import List
3
+
4
+ # set combo box to error msg instead of blank when cannot retrieve options from backend
5
+ ERROR_MSG = "Retrieval Failed"
6
+
7
+ # Used for status bar
8
+ STATE_READY = "Ready"
9
+ STATE_INIT = "Errors will be shown here"
10
+ STATE_URLERROR = "Network error"
11
+ STATE_RESET_DEFAULT = "All settings reset"
12
+ STATE_WAIT = "Please wait..."
13
+ STATE_DONE = "Done!"
14
+ STATE_INTERRUPT = "Interrupted!"
15
+
16
+ # Other currently hardcoded stuff
17
+ SHORT_TIMEOUT = 10
18
+ LONG_TIMEOUT = None # requests that might take "forever", i.e., image generation with high batch count
19
+ REFRESH_INTERVAL = 3000 # 3 seconds between auto-config refresh
20
+ ETA_REFRESH_INTERVAL = 1000 # 1 second between eta refresh
21
+ CFG_FOLDER = "krita" # which folder in ~/.config to store config
22
+ CFG_NAME = "krita_diff_plugin" # name of config file
23
+ EXT_CFG_NAME = "krita_diff_plugin_scripts" # name of config file
24
+ # selection mask can only be added after image is added, so timeout is needed
25
+ ADD_MASK_TIMEOUT = 200
26
+ THREADED = True
27
+ ROUTE_PREFIX = "/sdapi/interpause/"
28
+ OFFICIAL_ROUTE_PREFIX = "/sdapi/v1/"
29
+
30
+ # error messages
31
+ ERR_MISSING_CONFIG = "Report this bug, developer missed out a config key somewhere."
32
+ ERR_NO_DOCUMENT = "No document open yet!"
33
+ ERR_NO_CONNECTION = "Cannot reach backend!"
34
+ ERR_BAD_URL = "Invalid backend URL!"
35
+
36
+ # tab IDs
37
+ TAB_SDCOMMON = "krita_diff_sdcommon"
38
+ TAB_CONFIG = "krita_diff_config"
39
+ TAB_TXT2IMG = "krita_diff_txt2img"
40
+ TAB_IMG2IMG = "krita_diff_img2img"
41
+ TAB_INPAINT = "krita_diff_inpaint"
42
+ TAB_UPSCALE = "krita_diff_upscale"
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Defaults:
47
+ base_url: str = "http://127.0.0.1:7860"
48
+ encryption_key: str = ""
49
+ just_use_yaml: bool = False
50
+ create_mask_layer: bool = True
51
+ save_temp_images: bool = False
52
+ fix_aspect_ratio: bool = True
53
+ only_full_img_tiling: bool = True
54
+ filter_nsfw: bool = False
55
+ do_exact_steps: bool = True
56
+ sample_path: str = "."
57
+ minimize_ui: bool = False
58
+ first_setup: bool = True # only used for the initial docker layout
59
+ alt_dock_behavior: bool = False
60
+ hide_layers: bool = True
61
+
62
+ sd_model_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
63
+ sd_model: str = "model.ckpt"
64
+ sd_batch_size: int = 1
65
+ sd_batch_count: int = 1
66
+ sd_base_size: int = 512
67
+ sd_max_size: int = 768
68
+ sd_tiling: bool = False
69
+ upscaler_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
70
+ upscaler_name: str = "None"
71
+ face_restorer_model_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
72
+ face_restorer_model: str = "None"
73
+ codeformer_weight: float = 0.5
74
+ include_grid: bool = False
75
+
76
+ txt2img_prompt: str = ""
77
+ txt2img_negative_prompt: str = ""
78
+ txt2img_sampler_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
79
+ txt2img_sampler: str = "Euler a"
80
+ txt2img_steps: int = 20
81
+ txt2img_cfg_scale: float = 7.0
82
+ txt2img_denoising_strength: float = 0.7
83
+ txt2img_seed: str = ""
84
+ txt2img_highres: bool = False
85
+ txt2img_script: str = "None"
86
+ txt2img_script_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
87
+ # TODO: Seed variation
88
+
89
+ img2img_prompt: str = ""
90
+ img2img_negative_prompt: str = ""
91
+ img2img_sampler_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
92
+ img2img_sampler: str = "Euler a"
93
+ img2img_steps: int = 40
94
+ img2img_cfg_scale: float = 12.0
95
+ img2img_denoising_strength: float = 0.8
96
+ img2img_seed: str = ""
97
+ img2img_color_correct: bool = False
98
+ img2img_script: str = "None"
99
+ img2img_script_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
100
+
101
+ inpaint_prompt: str = ""
102
+ inpaint_negative_prompt: str = ""
103
+ inpaint_sampler_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
104
+ inpaint_sampler: str = "LMS"
105
+ inpaint_steps: int = 100
106
+ inpaint_cfg_scale: float = 5.0
107
+ inpaint_denoising_strength: float = 0.40
108
+ inpaint_seed: str = ""
109
+ inpaint_invert_mask: bool = False
110
+ # inpaint_mask_blur: int = 4
111
+ inpaint_fill_list: List[str] = field(
112
+ # NOTE: list order corresponds to number to use in internal API!!!
113
+ default_factory=lambda: ["blur", "preserve", "latent noise", "latent empty"]
114
+ )
115
+ inpaint_fill: str = "preserve"
116
+ # inpaint_full_res: bool = False
117
+ # inpaint_full_res_padding: int = 32
118
+ inpaint_color_correct: bool = False
119
+ inpaint_script: str = "None"
120
+ inpaint_script_list: List[str] = field(default_factory=lambda: [ERROR_MSG])
121
+ inpaint_mask_weight: float = 1.0
122
+
123
+ upscale_upscaler_name: str = "None"
124
+ upscale_downscale_first: bool = False
125
+
126
+
127
+ DEFAULTS = Defaults()
frontends/krita/krita_diff/docker.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from krita import DockWidget, QScrollArea
2
+
3
+ from .script import script
4
+ from .style import style
5
+
6
+
7
+ def create_docker(page):
8
+ class Docker(DockWidget):
9
+ def __init__(self, *args, **kwargs):
10
+ super(Docker, self).__init__(*args, **kwargs)
11
+ self.setWindowTitle(page.name)
12
+ self.create_interface()
13
+ self.update_interface()
14
+ self.connect_interface()
15
+ self.setWidget(self.widget)
16
+
17
+ def create_interface(self):
18
+ self.page_widget = page()
19
+ self.widget = QScrollArea()
20
+ self.widget.setStyleSheet(style)
21
+ self.widget.setWidget(self.page_widget)
22
+ self.widget.setWidgetResizable(True)
23
+
24
+ def update_interface(self):
25
+ self.page_widget.cfg_init()
26
+
27
+ def connect_interface(self):
28
+ self.page_widget.cfg_connect()
29
+ script.config_updated.connect(lambda: self.update_interface())
30
+
31
+ def canvasChanged(self, canvas):
32
+ pass
33
+
34
+ return Docker
frontends/krita/krita_diff/extension.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from krita import Extension, QMainWindow, QTimer
2
+
3
+ from .defaults import REFRESH_INTERVAL
4
+ from .script import script
5
+
6
+
7
+ class SDPluginExtension(Extension):
8
+ def __init__(self, instance):
9
+ super().__init__(instance)
10
+
11
+ self.instance = instance
12
+ # store original window docker config
13
+ self.dock_opts = None
14
+
15
+ def setup(self):
16
+ self.update_timer = QTimer()
17
+ self.update_timer.timeout.connect(lambda: script.action_update_config())
18
+ self.update_timer.start(REFRESH_INTERVAL)
19
+ script.config_updated.connect(lambda: self.update_global())
20
+ self.instance.notifier().windowCreated.connect(lambda: self.update_global())
21
+ script.action_update_config()
22
+
23
+ def update_global(self):
24
+ window = self.instance.activeWindow()
25
+ if not window:
26
+ return
27
+ qwin = window.qwindow()
28
+ if not self.dock_opts:
29
+ self.dock_opts = qwin.dockOptions()
30
+
31
+ # NOTE: This changes the default behaviour of Krita for all dockers!
32
+ if script.cfg("alt_dock_behavior", bool):
33
+ qwin.setDockOptions(
34
+ QMainWindow.AnimatedDocks
35
+ | QMainWindow.AllowTabbedDocks
36
+ | QMainWindow.GroupedDragging
37
+ | QMainWindow.AllowNestedDocks
38
+ # | QMainWindow.VerticalTabs
39
+ )
40
+ else:
41
+ qwin.setDockOptions(self.dock_opts)
42
+
43
+ def createActions(self, window):
44
+ txt2img_action = window.createAction(
45
+ "txt2img", "Apply txt2img", "tools/scripts"
46
+ )
47
+ txt2img_action.triggered.connect(lambda: script.action_txt2img())
48
+ img2img_action = window.createAction(
49
+ "img2img", "Apply img2img", "tools/scripts"
50
+ )
51
+ img2img_action.triggered.connect(lambda: script.action_img2img())
52
+ upscale_x_action = window.createAction(
53
+ "img2img_upscale", "Apply img2img upscale", "tools/scripts"
54
+ )
55
+ upscale_x_action.triggered.connect(lambda: script.action_sd_upscale())
56
+ upscale_x_action = window.createAction(
57
+ "img2img_inpaint", "Apply img2img inpaint", "tools/scripts"
58
+ )
59
+ upscale_x_action.triggered.connect(lambda: script.action_inpaint())
60
+ simple_upscale_action = window.createAction(
61
+ "simple_upscale", "Apply simple upscaler", "tools/scripts"
62
+ )
63
+ simple_upscale_action.triggered.connect(lambda: script.action_simple_upscale())
frontends/krita/krita_diff/krita_diff.action ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <ActionCollection version="2" name="Scripts">
3
+ <Actions category="Scripts">
4
+ <text>Stable Diffusion Plugin</text>
5
+
6
+ <Action name="txt2img">
7
+ <icon/>
8
+ <text>Apply txt2img transform</text>
9
+ <whatsThis/>
10
+ <toolTip/>
11
+ <iconText/>
12
+ <activationFlags>10000</activationFlags>
13
+ <activationConditions>0</activationConditions>
14
+ <shortcut>ctrl+alt+q</shortcut>
15
+ <isCheckable>false</isCheckable>
16
+ <statusTip/>
17
+ </Action>
18
+ <Action name="img2img">
19
+ <icon/>
20
+ <text>Apply img2img transform</text>
21
+ <whatsThis/>
22
+ <toolTip/>
23
+ <iconText/>
24
+ <activationFlags>10000</activationFlags>
25
+ <activationConditions>0</activationConditions>
26
+ <shortcut>ctrl+alt+w</shortcut>
27
+ <isCheckable>false</isCheckable>
28
+ <statusTip/>
29
+ </Action>
30
+ <!--
31
+ <Action name="img2img_upscale">
32
+ <icon/>
33
+ <text>Apply SD upscale transform</text>
34
+ <whatsThis/>
35
+ <toolTip/>
36
+ <iconText/>
37
+ <activationFlags>10000</activationFlags>
38
+ <activationConditions>0</activationConditions>
39
+ <shortcut>ctrl+alt+e</shortcut>
40
+ <isCheckable>false</isCheckable>
41
+ <statusTip/>
42
+ </Action>
43
+ -->
44
+ <Action name="img2img_inpaint">
45
+ <icon/>
46
+ <text>Apply inpaint transform</text>
47
+ <whatsThis/>
48
+ <toolTip/>
49
+ <iconText/>
50
+ <activationFlags>10000</activationFlags>
51
+ <activationConditions>0</activationConditions>
52
+ <shortcut>ctrl+alt+r</shortcut>
53
+ <isCheckable>false</isCheckable>
54
+ <statusTip/>
55
+ </Action>
56
+ <Action name="simple_upscale">
57
+ <icon/>
58
+ <text>Apply ESRGAN upscaler</text>
59
+ <whatsThis/>
60
+ <toolTip/>
61
+ <iconText/>
62
+ <activationFlags>10000</activationFlags>
63
+ <activationConditions>0</activationConditions>
64
+ <shortcut>ctrl+alt+t</shortcut>
65
+ <isCheckable>false</isCheckable>
66
+ <statusTip/>
67
+ </Action>
68
+ </Actions>
69
+ </ActionCollection>
frontends/krita/krita_diff/manual.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <h1>Stable Diffusion Plugin</h1>
2
+
3
+ <p>Expose the power of AUTOMATIC1111's Stable Diffusion fork for creating to your heart's desire.</p>
4
+
5
+ <p>Forked from <a href="https://github.com/sddebz/stable-diffusion-krita-plugin"
6
+ target="_blank">https://github.com/sddebz/stable-diffusion-krita-plugin</a>,
7
+ which is based on the feature-rich and performant <a href="https://github.com/AUTOMATIC1111/stable-diffusion-webui"
8
+ target="_blank">AUTOMATIC1111's Fork</a>.
9
+ </p>
10
+
11
+ <p>Usage guide at <a href="https://github.com/Interpause/auto-sd-paint-ext/wiki"
12
+ target="_blank">https://github.com/Interpause/auto-sd-paint-ext/wiki</a>.</p>
13
+ <p>Report bugs & suggest features at <a href="https://github.com/Interpause/auto-sd-paint-ext/issues"
14
+ target="_blank">https://github.com/Interpause/auto-sd-paint-ext/issues</a>.
15
+ </p>
frontends/krita/krita_diff/pages/__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from .common import SDCommonPage
2
+ from .config import ConfigPage
3
+ from .img2img import Img2ImgPage
4
+ from .inpaint import InpaintPage
5
+ from .txt2img import Txt2ImgPage
6
+ from .upscale import UpscalePage
frontends/krita/krita_diff/pages/common.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from krita import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
2
+
3
+ from ..script import script
4
+ from ..widgets import QCheckBox, QComboBoxLayout, QLabel, QSpinBoxLayout
5
+
6
+ # Notes:
7
+ # - move tiling mode to config?
8
+ # - move upscaler/face restorer to config?
9
+
10
+
11
+ class SDCommonPage(QWidget):
12
+ name = "SD Common Options"
13
+
14
+ def __init__(self, *args, **kwargs):
15
+ super(SDCommonPage, self).__init__(*args, **kwargs)
16
+
17
+ self.title = QLabel("<em>Quick Config</em>")
18
+
19
+ # Model list
20
+ self.sd_model_layout = QComboBoxLayout(
21
+ script.cfg, "sd_model_list", "sd_model", label="SD model:"
22
+ )
23
+
24
+ # batch size & count
25
+ self.batch_count_layout = QSpinBoxLayout(
26
+ script.cfg, "sd_batch_count", label="Batch count:", min=1, max=9999, step=1
27
+ )
28
+ self.batch_size_layout = QSpinBoxLayout(
29
+ script.cfg, "sd_batch_size", label="Batch size:", min=1, max=9999, step=1
30
+ )
31
+ batch_layout = QHBoxLayout()
32
+ batch_layout.addLayout(self.batch_count_layout)
33
+ batch_layout.addLayout(self.batch_size_layout)
34
+
35
+ # base/max size adjustment
36
+ self.base_size_layout = QSpinBoxLayout(
37
+ script.cfg, "sd_base_size", label="Base size:", min=64, max=8192, step=64
38
+ )
39
+ self.max_size_layout = QSpinBoxLayout(
40
+ script.cfg, "sd_max_size", label="Max size:", min=64, max=8192, step=64
41
+ )
42
+ size_layout = QHBoxLayout()
43
+ size_layout.addLayout(self.base_size_layout)
44
+ size_layout.addLayout(self.max_size_layout)
45
+
46
+ # global upscaler
47
+ self.upscaler_layout = QComboBoxLayout(
48
+ script.cfg, "upscaler_list", "upscaler_name", label="Upscaler:"
49
+ )
50
+
51
+ # Restore faces
52
+ self.face_restorer_layout = QComboBoxLayout(
53
+ script.cfg,
54
+ "face_restorer_model_list",
55
+ "face_restorer_model",
56
+ label="Face restorer:",
57
+ )
58
+ self.codeformer_weight_layout = QSpinBoxLayout(
59
+ script.cfg,
60
+ "codeformer_weight",
61
+ label="CodeFormer weight (max 0, min 1):",
62
+ step=0.01,
63
+ )
64
+
65
+ # Tiling mode
66
+ self.tiling = QCheckBox(script.cfg, "sd_tiling", "Tiling mode")
67
+
68
+ # Interrupt button
69
+ self.interrupt_btn = QPushButton("Interrupt")
70
+
71
+ layout = QVBoxLayout()
72
+ layout.setContentsMargins(0, 0, 0, 0)
73
+
74
+ layout.addWidget(self.title)
75
+ layout.addLayout(self.upscaler_layout)
76
+ layout.addLayout(self.face_restorer_layout)
77
+ layout.addLayout(self.codeformer_weight_layout)
78
+ layout.addWidget(self.tiling)
79
+ layout.addLayout(self.sd_model_layout)
80
+ layout.addLayout(batch_layout)
81
+ layout.addLayout(size_layout)
82
+ layout.addWidget(self.interrupt_btn)
83
+ layout.addStretch()
84
+
85
+ self.setLayout(layout)
86
+
87
+ def cfg_init(self):
88
+ self.sd_model_layout.cfg_init()
89
+ self.batch_count_layout.cfg_init()
90
+ self.batch_size_layout.cfg_init()
91
+ self.base_size_layout.cfg_init()
92
+ self.max_size_layout.cfg_init()
93
+ self.upscaler_layout.cfg_init()
94
+ self.face_restorer_layout.cfg_init()
95
+ self.codeformer_weight_layout.cfg_init()
96
+ self.tiling.cfg_init()
97
+
98
+ self.title.setVisible(not script.cfg("minimize_ui", bool))
99
+
100
+ def cfg_connect(self):
101
+ self.sd_model_layout.cfg_connect()
102
+ self.batch_count_layout.cfg_connect()
103
+ self.batch_size_layout.cfg_connect()
104
+ self.base_size_layout.cfg_connect()
105
+ self.max_size_layout.cfg_connect()
106
+ self.upscaler_layout.cfg_connect()
107
+ self.face_restorer_layout.cfg_connect()
108
+ self.codeformer_weight_layout.cfg_connect()
109
+ self.tiling.cfg_connect()
110
+
111
+ # Hide codeformer_weight when model isnt codeformer
112
+ def toggle_codeformer_weights(visible):
113
+ self.codeformer_weight_layout.qspin.setVisible(visible)
114
+ self.codeformer_weight_layout.qlabel.setVisible(visible)
115
+
116
+ self.face_restorer_layout.qcombo.currentTextChanged.connect(
117
+ lambda t: toggle_codeformer_weights(t == "CodeFormer")
118
+ )
119
+ toggle_codeformer_weights(
120
+ self.face_restorer_layout.qcombo.currentText() == "CodeFormer"
121
+ )
122
+
123
+ self.interrupt_btn.released.connect(lambda: script.action_interrupt())
frontends/krita/krita_diff/pages/config.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import partial
2
+
3
+ from krita import QHBoxLayout, QLineEdit, QPushButton, QVBoxLayout, QWidget
4
+
5
+ from ..defaults import DEFAULTS
6
+ from ..script import script
7
+ from ..utils import reset_docker_layout
8
+ from ..widgets import QCheckBox, QLabel, QLineEditLayout, StatusBar
9
+
10
+
11
+ class ConfigPage(QWidget):
12
+ name = "SD Plugin Config"
13
+
14
+ def __init__(self, *args, **kwargs):
15
+ super(ConfigPage, self).__init__(*args, **kwargs)
16
+
17
+ self.status_bar = StatusBar()
18
+
19
+ self.base_url = QLineEdit()
20
+ self.base_url_reset = QPushButton("Default")
21
+ inline1 = QHBoxLayout()
22
+ inline1.addWidget(self.base_url)
23
+ inline1.addWidget(self.base_url_reset)
24
+
25
+ self.enc_key = QLineEditLayout(
26
+ script.cfg, "encryption_key", "Optional Encryption Key"
27
+ )
28
+
29
+ # Plugin settings
30
+ self.just_use_yaml = QCheckBox(
31
+ script.cfg, "just_use_yaml", "(unrecommended) Ignore settings"
32
+ )
33
+ self.create_mask_layer = QCheckBox(
34
+ script.cfg, "create_mask_layer", "Use selection as mask"
35
+ )
36
+ self.save_temp_images = QCheckBox(
37
+ script.cfg, "save_temp_images", "Save images for debug"
38
+ )
39
+ self.fix_aspect_ratio = QCheckBox(
40
+ script.cfg, "fix_aspect_ratio", "Adjust selection aspect ratio"
41
+ )
42
+ self.only_full_img_tiling = QCheckBox(
43
+ script.cfg, "only_full_img_tiling", "Disallow tiling with selection"
44
+ )
45
+ self.include_grid = QCheckBox(
46
+ script.cfg, "include_grid", "Include txt2img/img2img grid"
47
+ )
48
+ self.minimize_ui = QCheckBox(script.cfg, "minimize_ui", "Squeeze the UI")
49
+ self.alt_docker = QCheckBox(
50
+ script.cfg, "alt_dock_behavior", "Alt Docker Behaviour"
51
+ )
52
+ self.hide_layers = QCheckBox(script.cfg, "hide_layers", "Auto hide layers")
53
+
54
+ # webUI/backend settings
55
+ self.filter_nsfw = QCheckBox(script.cfg, "filter_nsfw", "Filter NSFW")
56
+ self.img2img_color_correct = QCheckBox(
57
+ script.cfg, "img2img_color_correct", "Color correct img2img"
58
+ )
59
+ self.inpaint_color_correct = QCheckBox(
60
+ script.cfg, "inpaint_color_correct", "Color correct inpaint"
61
+ )
62
+ self.do_exact_steps = QCheckBox(
63
+ script.cfg,
64
+ "do_exact_steps",
65
+ "Exact number of steps for denoising",
66
+ )
67
+
68
+ self.refresh_btn = QPushButton("Auto-Refresh Options Now")
69
+ self.restore_defaults = QPushButton("Restore Defaults")
70
+
71
+ self.info_label = QLabel()
72
+ self.info_label.setOpenExternalLinks(True)
73
+ self.info_label.setWordWrap(True)
74
+
75
+ # scroll_area = QScrollArea()
76
+
77
+ layout = QVBoxLayout()
78
+ layout.setContentsMargins(0, 0, 0, 0)
79
+ layout_inner = QVBoxLayout()
80
+ layout_inner.setContentsMargins(0, 0, 0, 0)
81
+
82
+ layout_inner.addWidget(QLabel("<em>Plugin settings:</em>"))
83
+ layout_inner.addWidget(self.minimize_ui)
84
+ layout_inner.addWidget(self.alt_docker)
85
+ layout_inner.addWidget(self.hide_layers)
86
+ layout_inner.addWidget(self.create_mask_layer)
87
+ layout_inner.addWidget(self.fix_aspect_ratio)
88
+ layout_inner.addWidget(self.only_full_img_tiling)
89
+ layout_inner.addWidget(self.include_grid)
90
+ layout_inner.addWidget(self.save_temp_images)
91
+ # layout_inner.addWidget(self.just_use_yaml)
92
+
93
+ layout_inner.addWidget(QLabel("<em>Backend/webUI settings:</em>"))
94
+ layout_inner.addWidget(self.filter_nsfw)
95
+ layout_inner.addWidget(self.img2img_color_correct)
96
+ layout_inner.addWidget(self.inpaint_color_correct)
97
+ layout_inner.addWidget(self.do_exact_steps)
98
+
99
+ # TODO: figure out how to set height of scroll area when there are too many options
100
+ # or maybe an option search bar
101
+ # scroll_area.setLayout(layout_inner)
102
+ # scroll_area.setWidgetResizable(True)
103
+ # layout.addWidget(scroll_area)
104
+ layout.addWidget(self.status_bar)
105
+ layout.addWidget(QLabel("<em>Backend url:</em>"))
106
+ layout.addLayout(inline1)
107
+ layout.addLayout(self.enc_key)
108
+ layout.addLayout(layout_inner)
109
+ layout.addWidget(self.refresh_btn)
110
+ layout.addWidget(self.restore_defaults)
111
+ layout.addWidget(self.info_label)
112
+ layout.addStretch()
113
+
114
+ self.setLayout(layout)
115
+
116
+ def cfg_init(self):
117
+ # NOTE: update timer -> cfg_init, setText seems to reset cursor position so we prevent it
118
+ base_url = script.cfg("base_url", str)
119
+ if self.base_url.text() != base_url:
120
+ self.base_url.setText(base_url)
121
+
122
+ self.enc_key.cfg_init()
123
+ self.just_use_yaml.cfg_init()
124
+ self.create_mask_layer.cfg_init()
125
+ self.save_temp_images.cfg_init()
126
+ self.fix_aspect_ratio.cfg_init()
127
+ self.only_full_img_tiling.cfg_init()
128
+ self.include_grid.cfg_init()
129
+ self.filter_nsfw.cfg_init()
130
+ self.img2img_color_correct.cfg_init()
131
+ self.inpaint_color_correct.cfg_init()
132
+ self.do_exact_steps.cfg_init()
133
+ self.minimize_ui.cfg_init()
134
+ self.alt_docker.cfg_init()
135
+ self.hide_layers.cfg_init()
136
+
137
+ info_text = """
138
+ <em>Tip:</em> Only a selected few backend/webUI settings are exposed above.<br/>
139
+ <em>Tip:</em> You should look through & configure all the backend/webUI settings at least once.
140
+ <br/><br/>
141
+ <a href="http://127.0.0.1:7860/" target="_blank">Configure all settings in webUI</a><br/>
142
+ <a href="https://github.com/Interpause/auto-sd-paint-ext/wiki" target="_blank">Read the guide</a><br/>
143
+ <a href="https://github.com/Interpause/auto-sd-paint-ext/issues" target="_blank">Report bugs or suggest features</a>
144
+ """
145
+ if script.cfg("minimize_ui", bool):
146
+ info_text = "\n".join(info_text.split("\n")[-4:-1])
147
+ self.info_label.setText(info_text)
148
+
149
+ def cfg_connect(self):
150
+ self.base_url.textChanged.connect(partial(script.cfg.set, "base_url"))
151
+ # NOTE: this triggers on every keystroke; theres no focus lost signal...
152
+ self.base_url.textChanged.connect(lambda: script.action_update_config())
153
+ self.base_url_reset.released.connect(
154
+ lambda: self.base_url.setText(DEFAULTS.base_url)
155
+ )
156
+ self.enc_key.cfg_connect()
157
+ self.just_use_yaml.cfg_connect()
158
+ self.create_mask_layer.cfg_connect()
159
+ self.save_temp_images.cfg_connect()
160
+ self.fix_aspect_ratio.cfg_connect()
161
+ self.only_full_img_tiling.cfg_connect()
162
+ self.include_grid.cfg_connect()
163
+ self.filter_nsfw.cfg_connect()
164
+ self.img2img_color_correct.cfg_connect()
165
+ self.inpaint_color_correct.cfg_connect()
166
+ self.do_exact_steps.cfg_connect()
167
+ self.minimize_ui.cfg_connect()
168
+ self.alt_docker.cfg_connect()
169
+ self.hide_layers.cfg_connect()
170
+
171
+ def restore_defaults():
172
+ script.restore_defaults()
173
+ reset_docker_layout()
174
+ script.cfg.set("first_setup", False)
175
+ # retrieve list of available stuff again
176
+ script.action_update_config()
177
+
178
+ self.refresh_btn.released.connect(lambda: script.action_update_config())
179
+ self.restore_defaults.released.connect(restore_defaults)
180
+ self.minimize_ui.toggled.connect(lambda _: script.config_updated.emit())
181
+ self.alt_docker.toggled.connect(lambda _: script.config_updated.emit())
182
+ script.status_changed.connect(lambda s: self.status_bar.set_status(s))
frontends/krita/krita_diff/pages/extension.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from functools import partial
3
+ from typing import List
4
+
5
+ from krita import QVBoxLayout, QWidget
6
+
7
+ from ..config import Config
8
+ from ..script import script
9
+ from ..utils import get_ext_key
10
+ from ..widgets import (
11
+ QCheckBox,
12
+ QComboBoxLayout,
13
+ QLineEditLayout,
14
+ QMultiCheckBoxLayout,
15
+ QSpinBoxLayout,
16
+ )
17
+
18
+
19
+ # TODO: dynamically adjust script options available without needing to restart plugin
20
+ class ExtWidget(QWidget):
21
+ def __init__(self, ext_cfg: Config, ext_type: str, ext_name: str, *args, **kwargs):
22
+ """Dynamically create form for script based on metadata.
23
+
24
+ Args:
25
+ ext_cfg (Config): Config object to get metadata from.
26
+ ext_type (str): Whether metadata is in "scripts_txt2img", "scripts_img2img" or "scripts_inpaint"
27
+ ext_name (str): Name of script.
28
+ """
29
+ super(ExtWidget, self).__init__(*args, **kwargs)
30
+
31
+ get_key = partial(get_ext_key, ext_type, ext_name)
32
+
33
+ try:
34
+ meta: List[dict] = json.loads(ext_cfg(get_key()))
35
+ except json.JSONDecodeError:
36
+ meta = []
37
+ print(f"Script metadata is invalid: {ext_cfg(get_key())}")
38
+
39
+ layout = QVBoxLayout()
40
+ self.widgets = []
41
+ for i, o in enumerate(meta):
42
+ w = None
43
+ k = get_key(i)
44
+ if o["type"] == "range":
45
+ w = QSpinBoxLayout(
46
+ ext_cfg,
47
+ k,
48
+ label=o["label"],
49
+ min=o["min"],
50
+ max=o["max"],
51
+ step=o["step"],
52
+ )
53
+ elif o["type"] == "combo":
54
+ w = QComboBoxLayout(ext_cfg, o["opts"], k, label=o["label"])
55
+ elif o["type"] == "text":
56
+ w = QLineEditLayout(ext_cfg, k, o["label"])
57
+ elif o["type"] == "checkbox":
58
+ w = QCheckBox(ext_cfg, k, o["label"])
59
+ elif o["type"] == "multiselect":
60
+ w = QMultiCheckBoxLayout(ext_cfg, o["opts"], k, o["label"])
61
+ else:
62
+ continue
63
+ self.widgets.append(w)
64
+ if isinstance(w, QWidget):
65
+ layout.addWidget(w)
66
+ else:
67
+ layout.addLayout(w)
68
+ self.setLayout(layout)
69
+
70
+ def cfg_init(self):
71
+ for w in self.widgets:
72
+ w.cfg_init()
73
+
74
+ def cfg_connect(self):
75
+ for w in self.widgets:
76
+ w.cfg_connect()
77
+
78
+
79
+ class ExtSectionLayout(QVBoxLayout):
80
+ def __init__(self, cfg_prefix: str, *args, **kwargs):
81
+ super(ExtSectionLayout, self).__init__(*args, **kwargs)
82
+
83
+ # NOTE: backend will send empty scripts followed by the real one, have to
84
+ # detect for that
85
+ self.is_init = False
86
+
87
+ self.dropdown = QComboBoxLayout(
88
+ script.cfg,
89
+ f"{cfg_prefix}_script_list",
90
+ f"{cfg_prefix}_script",
91
+ label="Scripts:",
92
+ )
93
+ self.addLayout(self.dropdown)
94
+
95
+ self.ext_type = f"scripts_{cfg_prefix}"
96
+ self.ext_names = partial(script.cfg, f"{cfg_prefix}_script_list", "QStringList")
97
+ self.ext_widgets = {}
98
+
99
+ def init_ui_once_if_ready(self):
100
+ """Init UI only once, and only when its ready (aka metadata is present)."""
101
+ if self.is_init:
102
+ return
103
+ if len(self.ext_names()) != script.ext_cfg(f"{self.ext_type}_len", int):
104
+ return
105
+
106
+ self.is_init = True
107
+ for ext_name in self.ext_names():
108
+ ext_widget = ExtWidget(script.ext_cfg, self.ext_type, ext_name)
109
+ ext_widget.setVisible(False)
110
+ self.addWidget(ext_widget)
111
+ self.ext_widgets[ext_name] = ext_widget
112
+ self._cfg_connect()
113
+
114
+ def cfg_init(self):
115
+ self.dropdown.cfg_init()
116
+ self.init_ui_once_if_ready()
117
+ for widget in self.ext_widgets.values():
118
+ widget.cfg_init()
119
+
120
+ def cfg_connect(self):
121
+ self.dropdown.cfg_connect()
122
+ self.init_ui_once_if_ready()
123
+ self.dropdown.qcombo.currentTextChanged.connect(lambda s: self._update(s))
124
+
125
+ def _update(self, selected):
126
+ for w in self.ext_widgets.values():
127
+ w.setVisible(False)
128
+ widget = self.ext_widgets.get(selected, None)
129
+ if widget and selected != "None":
130
+ widget.setVisible(True)
131
+
132
+ def _cfg_connect(self):
133
+ for widget in self.ext_widgets.values():
134
+ widget.cfg_connect()
135
+ self._update(self.dropdown.qcombo.currentText())
frontends/krita/krita_diff/pages/img2img.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from krita import QPushButton
2
+
3
+ from ..script import script
4
+ from ..widgets import TipsLayout
5
+ from .img_base import SDImgPageBase
6
+
7
+
8
+ class Img2ImgPage(SDImgPageBase):
9
+ name = "Img2Img"
10
+
11
+ def __init__(self, *args, **kwargs):
12
+ super(Img2ImgPage, self).__init__(cfg_prefix="img2img", *args, **kwargs)
13
+
14
+ self.btn = QPushButton("Start img2img")
15
+ self.tips = TipsLayout(
16
+ ["Select what you want the model to perform img2img on."]
17
+ )
18
+
19
+ self.layout.addLayout(self.denoising_strength_layout)
20
+ self.layout.addWidget(self.btn)
21
+ self.layout.addLayout(self.tips)
22
+ self.layout.addStretch()
23
+
24
+ def cfg_init(self):
25
+ super(Img2ImgPage, self).cfg_init()
26
+
27
+ self.tips.setVisible(not script.cfg("minimize_ui", bool))
28
+
29
+ def cfg_connect(self):
30
+ super(Img2ImgPage, self).cfg_connect()
31
+ self.btn.released.connect(lambda: script.action_img2img())
frontends/krita/krita_diff/pages/img_base.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from krita import QHBoxLayout, QVBoxLayout, QWidget
2
+
3
+ from ..script import script
4
+ from ..widgets import (
5
+ QComboBoxLayout,
6
+ QLineEditLayout,
7
+ QPromptLayout,
8
+ QSpinBoxLayout,
9
+ StatusBar,
10
+ )
11
+ from .extension import ExtSectionLayout
12
+
13
+
14
+ class SDImgPageBase(QWidget):
15
+ def __init__(self, cfg_prefix: str, *args, **kwargs):
16
+ super(SDImgPageBase, self).__init__(*args, **kwargs)
17
+
18
+ self.status_bar = StatusBar()
19
+
20
+ self.prompt_layout = QPromptLayout(
21
+ script.cfg, f"{cfg_prefix}_prompt", f"{cfg_prefix}_negative_prompt"
22
+ )
23
+
24
+ self.seed_layout = QLineEditLayout(
25
+ script.cfg, f"{cfg_prefix}_seed", label="Seed:", placeholder="Random"
26
+ )
27
+
28
+ self.sampler_layout = QComboBoxLayout(
29
+ script.cfg,
30
+ f"{cfg_prefix}_sampler_list",
31
+ f"{cfg_prefix}_sampler",
32
+ label="Sampler:",
33
+ )
34
+
35
+ self.steps_layout = QSpinBoxLayout(
36
+ script.cfg, f"{cfg_prefix}_steps", label="Steps:", min=1, max=9999, step=1
37
+ )
38
+ self.cfg_scale_layout = QSpinBoxLayout(
39
+ script.cfg,
40
+ f"{cfg_prefix}_cfg_scale",
41
+ label="CFG scale:",
42
+ min=1.0,
43
+ max=9999.0,
44
+ )
45
+
46
+ self.ext_layout = ExtSectionLayout(cfg_prefix)
47
+
48
+ inline_layout = QHBoxLayout()
49
+ inline_layout.addLayout(self.steps_layout)
50
+ inline_layout.addLayout(self.cfg_scale_layout)
51
+
52
+ self.layout = layout = QVBoxLayout()
53
+ layout.setContentsMargins(0, 0, 0, 0)
54
+
55
+ layout.addWidget(self.status_bar)
56
+ layout.addLayout(self.ext_layout)
57
+ layout.addLayout(self.prompt_layout)
58
+ layout.addLayout(self.seed_layout)
59
+ layout.addLayout(self.sampler_layout)
60
+ layout.addLayout(inline_layout)
61
+
62
+ self.setLayout(layout)
63
+
64
+ # not added so inheritants can place it wherever they want
65
+ self.denoising_strength_layout = QSpinBoxLayout(
66
+ script.cfg,
67
+ f"{cfg_prefix}_denoising_strength",
68
+ label="Denoising strength:",
69
+ step=0.01,
70
+ )
71
+
72
+ def cfg_init(self):
73
+ self.ext_layout.cfg_init()
74
+ self.prompt_layout.cfg_init()
75
+ self.seed_layout.cfg_init()
76
+ self.sampler_layout.cfg_init()
77
+ self.steps_layout.cfg_init()
78
+ self.cfg_scale_layout.cfg_init()
79
+ self.denoising_strength_layout.cfg_init()
80
+
81
+ def cfg_connect(self):
82
+ self.ext_layout.cfg_connect()
83
+ self.prompt_layout.cfg_connect()
84
+ self.seed_layout.cfg_connect()
85
+ self.sampler_layout.cfg_connect()
86
+ self.steps_layout.cfg_connect()
87
+ self.cfg_scale_layout.cfg_connect()
88
+ self.denoising_strength_layout.cfg_connect()
89
+
90
+ script.status_changed.connect(lambda s: self.status_bar.set_status(s))
frontends/krita/krita_diff/pages/inpaint.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from krita import QHBoxLayout, QPushButton
2
+
3
+ from ..script import script
4
+ from ..widgets import QCheckBox, QComboBoxLayout, QSpinBoxLayout, TipsLayout
5
+ from .img_base import SDImgPageBase
6
+
7
+
8
+ class InpaintPage(SDImgPageBase):
9
+ name = "Inpaint"
10
+
11
+ def __init__(self, *args, **kwargs):
12
+ super(InpaintPage, self).__init__(cfg_prefix="inpaint", *args, **kwargs)
13
+ self.layout.addLayout(self.denoising_strength_layout)
14
+
15
+ self.invert_mask = QCheckBox(script.cfg, "inpaint_invert_mask", "Invert mask")
16
+ # self.mask_blur_layout = QSpinBoxLayout(
17
+ # script.cfg, "inpaint_mask_blur", "Mask blur (px):", min=0, max=9999, step=1
18
+ # )
19
+ self.inpaint_mask_weight = QSpinBoxLayout(
20
+ script.cfg, "inpaint_mask_weight", "Mask weight:", step=0.01
21
+ )
22
+
23
+ inline1 = QHBoxLayout()
24
+ inline1.addWidget(self.invert_mask)
25
+ inline1.addLayout(self.inpaint_mask_weight)
26
+ # inline1.addLayout(self.mask_blur_layout)
27
+
28
+ self.fill_layout = QComboBoxLayout(
29
+ script.cfg, "inpaint_fill_list", "inpaint_fill", label="Inpaint fill:"
30
+ )
31
+
32
+ # self.full_res = QCheckBox(script.cfg, "inpaint_full_res", "Inpaint full res")
33
+ # self.full_res_padding_layout = QSpinBoxLayout(
34
+ # script.cfg,
35
+ # "inpaint_full_res_padding",
36
+ # "Padding (px):",
37
+ # min=0,
38
+ # max=9999,
39
+ # step=1,
40
+ # )
41
+
42
+ inline2 = QHBoxLayout()
43
+ # inline2.addWidget(self.full_res)
44
+ # inline2.addLayout(self.full_res_padding_layout)
45
+
46
+ self.tips = TipsLayout(
47
+ [
48
+ "Ensure the inpaint layer is selected.",
49
+ "Select what the model will see when inpainting. <em>Inpaint full res</em> is unnecessary.",
50
+ ]
51
+ )
52
+ self.tips2 = TipsLayout(
53
+ [
54
+ '<a href="https://github.com/Interpause/auto-sd-paint-ext/wiki/Usage-Guide#inpainting" target="_blank">Inpaint Full Res & Mask Blur is obsolete; Click for new method.</a>'
55
+ ],
56
+ prefix="",
57
+ )
58
+ self.btn = QPushButton("Start inpaint")
59
+
60
+ self.layout.addLayout(self.fill_layout)
61
+ self.layout.addLayout(inline1)
62
+ self.layout.addLayout(inline2)
63
+ self.layout.addWidget(self.btn)
64
+ self.layout.addLayout(self.tips2)
65
+ self.layout.addLayout(self.tips)
66
+ self.layout.addStretch()
67
+
68
+ def cfg_init(self):
69
+ super(InpaintPage, self).cfg_init()
70
+ # self.mask_blur_layout.cfg_init()
71
+ self.fill_layout.cfg_init()
72
+ self.inpaint_mask_weight.cfg_init()
73
+ # self.full_res_padding_layout.cfg_init()
74
+ self.invert_mask.cfg_init()
75
+ # self.full_res.cfg_init()
76
+
77
+ self.tips.setVisible(not script.cfg("minimize_ui", bool))
78
+
79
+ def cfg_connect(self):
80
+ super(InpaintPage, self).cfg_connect()
81
+ # self.mask_blur_layout.cfg_connect()
82
+ self.fill_layout.cfg_connect()
83
+ self.inpaint_mask_weight.cfg_connect()
84
+ # self.full_res_padding_layout.cfg_connect()
85
+
86
+ self.invert_mask.cfg_connect()
87
+
88
+ # def toggle_fullres(enabled):
89
+ # # hide/show fullres padding
90
+ # self.full_res_padding_layout.qlabel.setVisible(enabled)
91
+ # self.full_res_padding_layout.qspin.setVisible(enabled)
92
+
93
+ # self.full_res.cfg_connect()
94
+ # self.full_res.toggled.connect(toggle_fullres)
95
+ # toggle_fullres(self.full_res.isChecked())
96
+
97
+ self.btn.released.connect(lambda: script.action_inpaint())
frontends/krita/krita_diff/pages/txt2img.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from krita import QHBoxLayout, QPushButton
2
+
3
+ from ..script import script
4
+ from ..widgets import QCheckBox, TipsLayout
5
+ from .img_base import SDImgPageBase
6
+
7
+
8
+ class Txt2ImgPage(SDImgPageBase):
9
+ name = "Txt2Img"
10
+
11
+ def __init__(self, *args, **kwargs):
12
+ super(Txt2ImgPage, self).__init__(cfg_prefix="txt2img", *args, **kwargs)
13
+
14
+ self.highres = QCheckBox(script.cfg, "txt2img_highres", "Highres fix")
15
+
16
+ inline_layout = QHBoxLayout()
17
+ inline_layout.addWidget(self.highres)
18
+ inline_layout.addLayout(self.denoising_strength_layout)
19
+
20
+ self.tips = TipsLayout(
21
+ ["Set base_size & max_size higher for AUTO's txt2img highres fix to work."]
22
+ )
23
+
24
+ self.btn = QPushButton("Start txt2img")
25
+
26
+ self.layout.addLayout(inline_layout)
27
+ self.layout.addWidget(self.btn)
28
+ self.layout.addLayout(self.tips)
29
+ self.layout.addStretch()
30
+
31
+ def cfg_init(self):
32
+ super(Txt2ImgPage, self).cfg_init()
33
+ self.highres.cfg_init()
34
+
35
+ self.tips.setVisible(not script.cfg("minimize_ui", bool))
36
+
37
+ def cfg_connect(self):
38
+ super(Txt2ImgPage, self).cfg_connect()
39
+
40
+ def toggle_highres(enabled):
41
+ # hide/show denoising strength
42
+ self.denoising_strength_layout.qlabel.setVisible(enabled)
43
+ self.denoising_strength_layout.qspin.setVisible(enabled)
44
+
45
+ self.highres.cfg_connect()
46
+ self.highres.toggled.connect(toggle_highres)
47
+ toggle_highres(self.highres.isChecked())
48
+
49
+ self.btn.released.connect(lambda: script.action_txt2img())
frontends/krita/krita_diff/pages/upscale.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from krita import QPushButton, QVBoxLayout, QWidget
2
+
3
+ from ..script import script
4
+ from ..widgets import QCheckBox, QComboBoxLayout, QLabel, StatusBar
5
+
6
+
7
+ # TODO: Become SD Upscale tab.
8
+ class UpscalePage(QWidget):
9
+ name = "Upscale"
10
+
11
+ def __init__(self, *args, **kwargs):
12
+ super(UpscalePage, self).__init__(*args, **kwargs)
13
+
14
+ self.status_bar = StatusBar()
15
+
16
+ self.upscaler_layout = QComboBoxLayout(
17
+ script.cfg, "upscaler_list", "upscale_upscaler_name", label="Upscaler:"
18
+ )
19
+
20
+ self.downscale_first = QCheckBox(
21
+ script.cfg,
22
+ "upscale_downscale_first",
23
+ "Downscale image x0.5 before upscaling",
24
+ )
25
+
26
+ self.note = QLabel(
27
+ """
28
+ NOTE:<br/>
29
+ - txt2img & img2img will use the <em>Quick Config</em> Upscaler when needing to scale up.<br/>
30
+ - Upscaling manually is only useful if the image was resized via Krita.<br/>
31
+ - In the future, SD Upscaling will replace this tab! For now, use the WebUI.
32
+ """
33
+ )
34
+ self.note.setWordWrap(True)
35
+
36
+ self.btn = QPushButton("Start upscaling")
37
+
38
+ layout = QVBoxLayout()
39
+ layout.setContentsMargins(0, 0, 0, 0)
40
+
41
+ layout.addWidget(self.status_bar)
42
+ layout.addWidget(self.note)
43
+ layout.addLayout(self.upscaler_layout)
44
+ layout.addWidget(self.downscale_first)
45
+ layout.addWidget(self.btn)
46
+ layout.addStretch()
47
+
48
+ self.setLayout(layout)
49
+
50
+ def cfg_init(self):
51
+ self.upscaler_layout.cfg_init()
52
+ self.downscale_first.cfg_init()
53
+
54
+ self.note.setVisible(not script.cfg("minimize_ui", bool))
55
+
56
+ def cfg_connect(self):
57
+ self.upscaler_layout.cfg_connect()
58
+ self.downscale_first.cfg_connect()
59
+ self.btn.released.connect(lambda: script.action_simple_upscale())
60
+ script.status_changed.connect(lambda s: self.status_bar.set_status(s))
frontends/krita/krita_diff/script.py ADDED
@@ -0,0 +1,425 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import itertools
2
+ import os
3
+ import time
4
+ from typing import Union
5
+
6
+ from krita import (
7
+ Document,
8
+ Krita,
9
+ Node,
10
+ QImage,
11
+ QObject,
12
+ Qt,
13
+ QTimer,
14
+ Selection,
15
+ pyqtSignal,
16
+ )
17
+
18
+ from .client import Client
19
+ from .config import Config
20
+ from .defaults import (
21
+ ADD_MASK_TIMEOUT,
22
+ ERR_NO_DOCUMENT,
23
+ ETA_REFRESH_INTERVAL,
24
+ EXT_CFG_NAME,
25
+ STATE_INTERRUPT,
26
+ STATE_RESET_DEFAULT,
27
+ STATE_WAIT,
28
+ )
29
+ from .utils import (
30
+ b64_to_img,
31
+ find_optimal_selection_region,
32
+ get_desc_from_resp,
33
+ img_to_ba,
34
+ save_img,
35
+ )
36
+
37
+
38
+ # Does it actually have to be a QObject?
39
+ # The only possible use I see is for event emitting
40
+ class Script(QObject):
41
+ cfg: Config
42
+ """config singleton"""
43
+ client: Client
44
+ """API client singleton"""
45
+ status: str
46
+ """Current status (shown in status bar)"""
47
+ app: Krita
48
+ """Krita's Application instance (KDE Application)"""
49
+ doc: Document
50
+ """Currently opened document if any"""
51
+ node: Node
52
+ """Currently selected layer in Krita"""
53
+ selection: Selection
54
+ """Selection region in Krita"""
55
+ x: int
56
+ """Left position of selection"""
57
+ y: int
58
+ """Top position of selection"""
59
+ width: int
60
+ """Width of selection"""
61
+ height: int
62
+ """Height of selection"""
63
+ status_changed = pyqtSignal(str)
64
+ config_updated = pyqtSignal()
65
+
66
+ def __init__(self):
67
+ super(Script, self).__init__()
68
+ # Persistent settings (should reload between Krita sessions)
69
+ self.cfg = Config()
70
+ # used for webUI scripts aka extensions not to be confused with their extensions
71
+ self.ext_cfg = Config(name=EXT_CFG_NAME, model=None)
72
+ self.client = Client(self.cfg, self.ext_cfg)
73
+ self.client.status.connect(self.status_changed.emit)
74
+ self.client.config_updated.connect(self.config_updated.emit)
75
+ self.eta_timer = QTimer()
76
+ self.eta_timer.setInterval(ETA_REFRESH_INTERVAL)
77
+ self.eta_timer.timeout.connect(lambda: self.action_update_eta())
78
+
79
+ def restore_defaults(self, if_empty=False):
80
+ """Restore to default config."""
81
+ self.cfg.restore_defaults(not if_empty)
82
+ self.ext_cfg.config.remove("")
83
+
84
+ if not if_empty:
85
+ self.status_changed.emit(STATE_RESET_DEFAULT)
86
+
87
+ def update_selection(self):
88
+ """Update references to key Krita objects as well as selection information."""
89
+ self.app = Krita.instance()
90
+ self.doc = self.app.activeDocument()
91
+
92
+ # self.doc doesnt exist at app startup
93
+ if not self.doc:
94
+ self.status_changed.emit(ERR_NO_DOCUMENT)
95
+ return
96
+
97
+ self.node = self.doc.activeNode()
98
+ self.selection = self.doc.selection()
99
+
100
+ is_not_selected = (
101
+ self.selection is None
102
+ or self.selection.width() < 1
103
+ or self.selection.height() < 1
104
+ )
105
+ if is_not_selected:
106
+ self.x = 0
107
+ self.y = 0
108
+ self.width = self.doc.width()
109
+ self.height = self.doc.height()
110
+ self.selection = None # for the two other cases of invalid selection
111
+ else:
112
+ self.x = self.selection.x()
113
+ self.y = self.selection.y()
114
+ self.width = self.selection.width()
115
+ self.height = self.selection.height()
116
+
117
+ assert (
118
+ self.doc.colorDepth() == "U8"
119
+ ), f'Only "8-bit integer/channel" supported, Document Color Depth: {self.doc.colorDepth()}'
120
+ assert (
121
+ self.doc.colorModel() == "RGBA"
122
+ ), f'Only "RGB/Alpha" supported, Document Color Model: {self.doc.colorModel()}'
123
+
124
+ def adjust_selection(self):
125
+ """Adjust selection region to account for scaling and striding to prevent image stretch."""
126
+ if self.selection is not None and self.cfg("fix_aspect_ratio", bool):
127
+ x, y, width, height = find_optimal_selection_region(
128
+ self.cfg("sd_base_size", int),
129
+ self.cfg("sd_max_size", int),
130
+ self.x,
131
+ self.y,
132
+ self.width,
133
+ self.height,
134
+ self.doc.width(),
135
+ self.doc.height(),
136
+ )
137
+
138
+ self.x = x
139
+ self.y = y
140
+ self.width = width
141
+ self.height = height
142
+
143
+ def get_selection_image(self) -> QImage:
144
+ """QImage of selection"""
145
+ return QImage(
146
+ self.doc.pixelData(self.x, self.y, self.width, self.height),
147
+ self.width,
148
+ self.height,
149
+ QImage.Format_RGBA8888,
150
+ ).rgbSwapped()
151
+
152
+ def get_mask_image(self) -> Union[QImage, None]:
153
+ """QImage of mask layer for inpainting"""
154
+ if self.node.type() not in {"paintlayer", "filelayer"}:
155
+ return None
156
+
157
+ return QImage(
158
+ self.node.pixelData(self.x, self.y, self.width, self.height),
159
+ self.width,
160
+ self.height,
161
+ QImage.Format_RGBA8888,
162
+ ).rgbSwapped()
163
+
164
+ def img_inserter(self, x, y, width, height, group: str = None):
165
+ """Return frozen image inserter to insert images as new layer."""
166
+ # Selection may change before callback, so freeze selection region
167
+ has_selection = self.selection is not None
168
+ glayer = self.doc.createGroupLayer(group) if group else None
169
+
170
+ def create_layer(name: str):
171
+ """Create new layer in document or group"""
172
+ layer = self.doc.createNode(name, "paintLayer")
173
+ parent = self.doc.rootNode()
174
+ if glayer:
175
+ glayer.addChildNode(layer, None)
176
+ parent.addChildNode(glayer, None)
177
+ else:
178
+ parent.addChildNode(layer, None)
179
+ return layer
180
+
181
+ # TODO: Insert images inside a group layer for better organization
182
+ # Group layer name can contain model name, prompt, etc
183
+ def insert(layer_name, enc):
184
+ nonlocal x, y, width, height, has_selection
185
+ print(f"inserting layer {layer_name}")
186
+ print(f"data size: {len(enc)}")
187
+
188
+ # QImage.Format_RGB32 (4) is default format after decoding image
189
+ # QImage.Format_RGBA8888 (17) is format used in Krita tutorial
190
+ # both are compatible, & converting from 4 to 17 required a RGB swap
191
+ # Likewise for 5 & 18 (their RGBA counterparts)
192
+ image = b64_to_img(enc)
193
+ print(
194
+ f"image created: {image}, {image.width()}x{image.height()}, depth: {image.depth()}, format: {image.format()}"
195
+ )
196
+
197
+ # NOTE: Scaling is usually done by backend (although I am reconsidering this)
198
+ # The scaling here is for SD Upscale or Upscale on a selection region rather than whole image
199
+ # Image won't be scaled down ONLY if there is no selection; i.e. selecting whole image will scale down,
200
+ # not selecting anything won't scale down, leading to the canvas being resized afterwards
201
+ if has_selection and (image.width() != width or image.height() != height):
202
+ print(f"Rescaling image to selection: {width}x{height}")
203
+ image = image.scaled(
204
+ width, height, transformMode=Qt.SmoothTransformation
205
+ )
206
+
207
+ # Resize (not scale!) canvas if image is larger (i.e. outpainting or Upscale was used)
208
+ if image.width() > self.doc.width() or image.height() > self.doc.height():
209
+ # NOTE:
210
+ # - user's selection will be partially ignored if image is larger than canvas
211
+ # - it is complex to scale/resize the image such that image fits in the newly scaled selection
212
+ # - the canvas will still be resized even if the image fits after transparency masking
213
+ print("Image is larger than canvas! Resizing...")
214
+ new_width, new_height = self.doc.width(), self.doc.height()
215
+ if image.width() > self.doc.width():
216
+ x, width, new_width = 0, image.width(), image.width()
217
+ if image.height() > self.doc.height():
218
+ y, height, new_height = 0, image.height(), image.height()
219
+ self.doc.resizeImage(0, 0, new_width, new_height)
220
+
221
+ ba = img_to_ba(image)
222
+ layer = create_layer(layer_name)
223
+ # layer.setColorSpace() doesn't pernamently convert layer depth etc...
224
+
225
+ # Don't fail silently for setPixelData(); fails if bit depth or number of channels mismatch
226
+ size = ba.size()
227
+ expected = layer.pixelData(x, y, width, height).size()
228
+ assert expected == size, f"Raw data size: {size}, Expected size: {expected}"
229
+
230
+ print(f"inserting at x: {x}, y: {y}, w: {width}, h: {height}")
231
+ layer.setPixelData(ba, x, y, width, height)
232
+ return layer
233
+
234
+ if glayer:
235
+ return insert, glayer
236
+ return insert
237
+
238
+ def apply_txt2img(self):
239
+ # freeze selection region
240
+ insert, glayer = self.img_inserter(
241
+ self.x, self.y, self.width, self.height, group="a"
242
+ )
243
+ mask_trigger = self.transparency_mask_inserter()
244
+
245
+ def cb(response):
246
+ if len(self.client.long_reqs) == 1: # last request
247
+ self.eta_timer.stop()
248
+ assert response is not None, "Backend Error, check terminal"
249
+ outputs = response["outputs"]
250
+ glayer_name, layer_names = get_desc_from_resp(response, "txt2img")
251
+ layers = [
252
+ insert(name if name else f"txt2img {i + 1}", output)
253
+ for output, name, i in zip(outputs, layer_names, itertools.count())
254
+ ]
255
+ if self.cfg("hide_layers", bool):
256
+ for layer in layers[:-1]:
257
+ layer.setVisible(False)
258
+ glayer.setName(glayer_name)
259
+ self.doc.refreshProjection()
260
+ mask_trigger(layers)
261
+
262
+ self.eta_timer.start(ETA_REFRESH_INTERVAL)
263
+ self.client.post_txt2img(
264
+ cb, self.width, self.height, self.selection is not None
265
+ )
266
+
267
+ def apply_img2img(self, mode):
268
+ insert, glayer = self.img_inserter(
269
+ self.x, self.y, self.width, self.height, group="a"
270
+ )
271
+ mask_trigger = self.transparency_mask_inserter()
272
+ mask_image = self.get_mask_image()
273
+
274
+ path = os.path.join(self.cfg("sample_path", str), f"{int(time.time())}.png")
275
+ mask_path = os.path.join(
276
+ self.cfg("sample_path", str), f"{int(time.time())}_mask.png"
277
+ )
278
+ if mode == 1 and mask_image is not None:
279
+ if self.cfg("save_temp_images", bool):
280
+ save_img(mask_image, mask_path)
281
+ # auto-hide mask layer before getting selection image
282
+ self.node.setVisible(False)
283
+ self.doc.refreshProjection()
284
+
285
+ sel_image = self.get_selection_image()
286
+ if self.cfg("save_temp_images", bool):
287
+ save_img(sel_image, path)
288
+
289
+ def cb(response):
290
+ if len(self.client.long_reqs) == 1: # last request
291
+ self.eta_timer.stop()
292
+ assert response is not None, "Backend Error, check terminal"
293
+
294
+ outputs = response["outputs"]
295
+ layer_name_prefix = (
296
+ "inpaint" if mode == 1 else "sd upscale" if mode == 2 else "img2img"
297
+ )
298
+ glayer_name, layer_names = get_desc_from_resp(response, layer_name_prefix)
299
+ layers = [
300
+ insert(name if name else f"{layer_name_prefix} {i + 1}", output)
301
+ for output, name, i in zip(outputs, layer_names, itertools.count())
302
+ ]
303
+ if self.cfg("hide_layers", bool):
304
+ for layer in layers[:-1]:
305
+ layer.setVisible(False)
306
+ glayer.setName(glayer_name)
307
+ self.doc.refreshProjection()
308
+ # dont need transparency mask for inpaint mode
309
+ if mode == 0:
310
+ mask_trigger(layers)
311
+
312
+ method = self.client.post_inpaint if mode == 1 else self.client.post_img2img
313
+ self.eta_timer.start()
314
+ method(
315
+ cb,
316
+ sel_image,
317
+ mask_image, # is unused by backend in img2img mode
318
+ self.selection is not None,
319
+ )
320
+
321
+ def apply_simple_upscale(self):
322
+ insert = self.img_inserter(self.x, self.y, self.width, self.height)
323
+ sel_image = self.get_selection_image()
324
+
325
+ path = os.path.join(self.cfg("sample_path", str), f"{int(time.time())}.png")
326
+ if self.cfg("save_temp_images", bool):
327
+ save_img(sel_image, path)
328
+
329
+ def cb(response):
330
+ assert response is not None, "Backend Error, check terminal"
331
+ output = response["output"]
332
+ insert(f"upscale", output)
333
+ self.doc.refreshProjection()
334
+
335
+ self.client.post_upscale(cb, sel_image)
336
+
337
+ def transparency_mask_inserter(self):
338
+ """Mask out extra regions due to adjust_selection()."""
339
+ orig_selection = self.selection.duplicate() if self.selection else None
340
+
341
+ def add_mask(layers):
342
+ self.doc.waitForDone()
343
+ cur_selection = self.selection
344
+ cur_layer = self.doc.activeNode()
345
+ for layer in layers:
346
+ self.doc.setActiveNode(layer)
347
+ self.doc.setSelection(orig_selection)
348
+ self.app.action("add_new_transparency_mask").trigger()
349
+ self.doc.setSelection(cur_selection) # reset to current selection
350
+ self.doc.setActiveNode(cur_layer) # reset to current layer
351
+
352
+ def trigger_mask_adding(layers):
353
+ if self.cfg("create_mask_layer", bool):
354
+ # need timeout to ensure layer exists first else crash
355
+ QTimer.singleShot(ADD_MASK_TIMEOUT, lambda: add_mask(layers))
356
+
357
+ return trigger_mask_adding
358
+
359
+ # Actions
360
+ def action_txt2img(self):
361
+ self.status_changed.emit(STATE_WAIT)
362
+ self.update_selection()
363
+ if not self.doc:
364
+ return
365
+ self.adjust_selection()
366
+ self.apply_txt2img()
367
+
368
+ def action_img2img(self):
369
+ self.status_changed.emit(STATE_WAIT)
370
+ self.update_selection()
371
+ if not self.doc:
372
+ return
373
+ self.adjust_selection()
374
+ self.apply_img2img(mode=0)
375
+
376
+ def action_sd_upscale(self):
377
+ assert False, "disabled"
378
+ self.status_changed.emit(STATE_WAIT)
379
+ self.update_selection()
380
+ self.apply_img2img(mode=2)
381
+
382
+ def action_inpaint(self):
383
+ self.status_changed.emit(STATE_WAIT)
384
+ self.update_selection()
385
+ if not self.doc:
386
+ return
387
+ self.adjust_selection()
388
+ self.apply_img2img(mode=1)
389
+
390
+ def action_simple_upscale(self):
391
+ self.status_changed.emit(STATE_WAIT)
392
+ self.update_selection()
393
+ if not self.doc:
394
+ return
395
+ self.apply_simple_upscale()
396
+
397
+ def action_update_config(self):
398
+ """Update certain config/state from the backend."""
399
+ self.client.get_config()
400
+
401
+ def action_interrupt(self):
402
+ def cb(resp=None):
403
+ self.status_changed.emit(STATE_INTERRUPT)
404
+
405
+ self.client.post_interrupt(cb)
406
+
407
+ def action_update_eta(self):
408
+ def cb(resp=None):
409
+ # print(resp)
410
+ # NOTE: progress & eta_relative is bugged upstream when there is multiple jobs
411
+ # so we use a substitute that seems to work
412
+ state = resp["state"]
413
+ cur_step = state["sampling_step"]
414
+ total_steps = state["sampling_steps"]
415
+ # doesnt take into account batch count
416
+ num_jobs = len(self.client.long_reqs) - 1
417
+
418
+ self.status_changed.emit(
419
+ f"Step {cur_step}/{total_steps} ({num_jobs} in queue)"
420
+ )
421
+
422
+ self.client.get_progress(cb)
423
+
424
+
425
+ script = Script()
frontends/krita/krita_diff/style.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # NOTE: see https://bugreports.qt.io/browse/QTBUG-22862
2
+ # We cant set layout padding via stylesheet
3
+
4
+ style = """
5
+ QTabBar::tab {
6
+ padding: 4px 2px 4px 2px;
7
+ }
8
+ """
frontends/krita/krita_diff/utils.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import re
3
+ from itertools import cycle
4
+ from math import ceil
5
+
6
+ from krita import Krita, QBuffer, QByteArray, QImage, QIODevice, Qt
7
+
8
+ from .config import Config
9
+ from .defaults import (
10
+ TAB_CONFIG,
11
+ TAB_IMG2IMG,
12
+ TAB_INPAINT,
13
+ TAB_SDCOMMON,
14
+ TAB_TXT2IMG,
15
+ TAB_UPSCALE,
16
+ )
17
+
18
+
19
+ def fix_prompt(prompt: str):
20
+ """Replace empty prompts with None."""
21
+ return prompt if prompt != "" else None
22
+
23
+
24
+ def get_ext_key(ext_type: str, ext_name: str, index: int = None):
25
+ """Get name of config key where the ext values would be stored."""
26
+ return "_".join(
27
+ [
28
+ ext_type,
29
+ re.sub(r"\W+", "", ext_name.lower()),
30
+ "meta" if index is None else str(index),
31
+ ]
32
+ )
33
+
34
+
35
+ def get_ext_args(ext_cfg: Config, ext_type: str, ext_name: str):
36
+ """Get args for script in positional list form."""
37
+ raw = ext_cfg(get_ext_key(ext_type, ext_name))
38
+ meta = []
39
+ try:
40
+ meta = json.loads(raw)
41
+ except json.JSONDecodeError:
42
+ print(f"Invalid metadata: {raw}")
43
+ args = []
44
+ for i, o in enumerate(meta):
45
+ typ = type(o["val"])
46
+ if issubclass(typ, list):
47
+ typ = "QStringList"
48
+ val = ext_cfg(get_ext_key(ext_type, ext_name, i), typ)
49
+ args.append(val)
50
+ return args
51
+
52
+
53
+ def find_fixed_aspect_ratio(
54
+ base_size: int, max_size: int, orig_width: int, orig_height: int
55
+ ):
56
+ """Copy of `krita_server.utils.sddebz_highres_fix()`.
57
+
58
+ This is used by `find_optimal_selection_region()` below to adjust the selected region.
59
+ """
60
+
61
+ def rnd(r, x, z=64):
62
+ """Scale dimension x with stride z while attempting to preserve aspect ratio r."""
63
+ return z * ceil(r * x / z)
64
+
65
+ ratio = orig_width / orig_height
66
+
67
+ # height is smaller dimension
68
+ if orig_width > orig_height:
69
+ width, height = rnd(ratio, base_size), base_size
70
+ if width > max_size:
71
+ width, height = max_size, rnd(1 / ratio, max_size)
72
+ # width is smaller dimension
73
+ else:
74
+ width, height = base_size, rnd(1 / ratio, base_size)
75
+ if height > max_size:
76
+ width, height = rnd(ratio, max_size), max_size
77
+
78
+ return width / height
79
+
80
+
81
+ def find_optimal_selection_region(
82
+ base_size: int,
83
+ max_size: int,
84
+ orig_x: int,
85
+ orig_y: int,
86
+ orig_width: int,
87
+ orig_height: int,
88
+ canvas_width: int,
89
+ canvas_height: int,
90
+ ):
91
+ """Adjusts the selected region in order to attempt to preserve the original
92
+ aspect ratio of the selection. This prevents the image from being stretched
93
+ after being scaled and strided.
94
+
95
+ After grasping what @sddebz intended to do, I fixed some logical errors &
96
+ made it clearer.
97
+
98
+ Iterating the padding is naive, but easier to understand & verify then figuring
99
+ out how to grow the rectangle using the fixed aspect ratio alone while accounting
100
+ for the canvas boundary. Also, it only grows the selection, not shrink, to
101
+ prevent clipping what the user selected.
102
+
103
+ Args:
104
+ base_size (int): Native/base input size of the model.
105
+ max_size (int): Max input size to accept.
106
+ orig_x (int): Original left position of selection.
107
+ orig_y (int): Original top position of selection.
108
+ orig_width (int): Original width of selection.
109
+ orig_height (int): Original height of selection.
110
+ canvas_width (int): Canvas width.
111
+ canvas_height (int): Canvas height.
112
+
113
+ Returns:
114
+ Tuple[int, int, int, int]: Best x, y, width, height to use.
115
+ """
116
+ orig_ratio = orig_width / orig_height
117
+ fix_ratio = find_fixed_aspect_ratio(base_size, max_size, orig_width, orig_height)
118
+
119
+ # h * (w/h - w/h) = w
120
+ xpad_limit = ceil(abs(fix_ratio - orig_ratio) * orig_height) * 2
121
+ # w * (h/w - h/w) = h
122
+ ypad_limit = ceil(abs(1 / fix_ratio - 1 / orig_ratio) * orig_width) * 2
123
+
124
+ best_x = orig_x
125
+ best_y = orig_y
126
+ best_width = orig_width
127
+ best_height = orig_height
128
+ best_delta = abs(fix_ratio - orig_ratio)
129
+ for x in range(1, xpad_limit + 1):
130
+ for y in range(1, ypad_limit + 1):
131
+ # account for boundary of canvas
132
+ # padding is on both sides i.e the selection grows while center anchored
133
+ x1 = max(0, orig_x - x // 2)
134
+ x2 = min(canvas_width, x1 + orig_width + x)
135
+ y1 = max(0, orig_y - y // 2)
136
+ y2 = min(canvas_height, y1 + orig_height + y)
137
+
138
+ new_width = x2 - x1
139
+ new_height = y2 - y1
140
+ new_ratio = new_width / new_height
141
+ new_delta = abs(fix_ratio - new_ratio)
142
+ if new_delta < best_delta:
143
+ best_delta = new_delta
144
+ best_x = x1
145
+ best_y = y1
146
+ best_width = new_width
147
+ best_height = new_height
148
+
149
+ return best_x, best_y, best_width, best_height
150
+
151
+
152
+ def save_img(img: QImage, path: str):
153
+ """Expects QImage"""
154
+ # png is lossless; setting compression to max (0) won't affect quality
155
+ # NOTE: save_img WILL FAIL when using remote backend
156
+ try:
157
+ img.save(path, "PNG", 0)
158
+ except:
159
+ pass
160
+
161
+
162
+ def img_to_ba(img: QImage):
163
+ """Converts QImage to QByteArray"""
164
+ ptr = img.bits()
165
+ ptr.setsize(img.byteCount())
166
+ return QByteArray(ptr.asstring())
167
+
168
+
169
+ def img_to_b64(img: QImage):
170
+ """Converts QImage to base64-encoded string"""
171
+ ba = QByteArray()
172
+ buffer = QBuffer(ba)
173
+ buffer.open(QIODevice.WriteOnly)
174
+ img.save(buffer, "PNG", 0)
175
+ return ba.toBase64().data().decode("utf-8")
176
+
177
+
178
+ def b64_to_img(enc: str):
179
+ """Converts base64-encoded string to QImage"""
180
+ ba = QByteArray.fromBase64(enc.encode("utf-8"))
181
+ return QImage.fromData(ba, "PNG")
182
+
183
+
184
+ def bytewise_xor(msg: bytes, key: bytes):
185
+ """Used for decrypting/encrypting request/response bodies."""
186
+ return bytes(v ^ k for v, k in zip(msg, cycle(key)))
187
+
188
+
189
+ def get_desc_from_resp(resp: dict, type: str = ""):
190
+ """Get description of image generation from backend response."""
191
+ try:
192
+ info = json.loads(resp["info"])
193
+ seeds = info["all_seeds"]
194
+ glayer_desc = f"""[{type}]
195
+ Prompt: {info['prompt']},
196
+ Negative Prompt: {info['negative_prompt']},
197
+ Model: {info['sd_model_hash']},
198
+ Sampler: {info['sampler_name']},
199
+ Scale: {info['cfg_scale']},
200
+ Steps: {info['steps']}"""
201
+ layers_desc = []
202
+ for (seed,) in zip(seeds):
203
+ layers_desc.append(f"Seed: {seed}")
204
+ return glayer_desc, layers_desc
205
+ except:
206
+ return f"[{type}]", cycle([None])
207
+
208
+
209
+ def reset_docker_layout():
210
+ """NOTE: Default stacking of dockers hardcoded here."""
211
+ docker_ids = {
212
+ TAB_SDCOMMON,
213
+ TAB_CONFIG,
214
+ TAB_IMG2IMG,
215
+ TAB_TXT2IMG,
216
+ TAB_UPSCALE,
217
+ TAB_INPAINT,
218
+ }
219
+ instance = Krita.instance()
220
+ # Assumption that currently active window is the main window
221
+ window = instance.activeWindow()
222
+ dockers = {
223
+ d.objectName(): d for d in instance.dockers() if d.objectName() in docker_ids
224
+ }
225
+ qmainwindow = window.qwindow()
226
+ # Reset all dockers
227
+ for d in dockers.values():
228
+ d.setFloating(False)
229
+ d.setVisible(True)
230
+ qmainwindow.addDockWidget(Qt.LeftDockWidgetArea, d)
231
+
232
+ qmainwindow.tabifyDockWidget(dockers[TAB_SDCOMMON], dockers[TAB_CONFIG])
233
+ qmainwindow.tabifyDockWidget(dockers[TAB_TXT2IMG], dockers[TAB_IMG2IMG])
234
+ qmainwindow.tabifyDockWidget(dockers[TAB_TXT2IMG], dockers[TAB_INPAINT])
235
+ qmainwindow.tabifyDockWidget(dockers[TAB_TXT2IMG], dockers[TAB_UPSCALE])
236
+ dockers[TAB_SDCOMMON].raise_()
237
+ dockers[TAB_INPAINT].raise_()
frontends/krita/krita_diff/widgets/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from .checkbox import QCheckBox, QMultiCheckBoxLayout
2
+ from .combo_box import QComboBoxLayout
3
+ from .line_edit import QLineEditLayout
4
+ from .misc import QLabel
5
+ from .prompt import QPromptLayout
6
+ from .spin_box import QSpinBoxLayout
7
+ from .status_bar import StatusBar
8
+ from .tips import TipsLayout
frontends/krita/krita_diff/widgets/checkbox.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import partial
2
+
3
+ from krita import QCheckBox as _QCheckBox
4
+ from krita import QHBoxLayout, QVBoxLayout
5
+
6
+ from ..config import Config
7
+ from .misc import QLabel
8
+
9
+
10
+ class QCheckBox(_QCheckBox):
11
+ def __init__(self, cfg: Config, field_cfg: str, label: str = None, *args, **kwargs):
12
+ """QCheckBox compatible with the config system.
13
+
14
+ Args:
15
+ cfg (Config): Config to connect to.
16
+ field_cfg (str): Config key to read/write value to.
17
+ label (str): Label, uses `field_cfg` if None. Defaults to None.
18
+ """
19
+ label = field_cfg if label is None else label
20
+ super(QCheckBox, self).__init__(label, *args, **kwargs)
21
+
22
+ self.cfg = cfg
23
+ self.field_cfg = field_cfg
24
+
25
+ def cfg_init(self):
26
+ self.setChecked(self.cfg(self.field_cfg, bool))
27
+
28
+ def cfg_connect(self):
29
+ self.toggled.connect(partial(self.cfg.set, self.field_cfg))
30
+
31
+
32
+ # TODO: adjust number of checkboxes based on options without needing restart
33
+ class QMultiCheckBoxLayout(QVBoxLayout):
34
+ def __init__(
35
+ self,
36
+ cfg: Config,
37
+ options_cfg: list,
38
+ selected_cfg: str,
39
+ label: str = None,
40
+ *args,
41
+ **kwargs
42
+ ):
43
+ """Layout for labelled multi-select CheckBox.
44
+
45
+ Args:
46
+ cfg (Config): Config to connect to.
47
+ options_cfg (list): List of options.
48
+ selected_cfg (str): Config key to read/write selected options to.
49
+ label (str, optional): Label, uses `selected_cfg` if None. Defaults to None.
50
+ """
51
+ super(QMultiCheckBoxLayout, self).__init__(*args, **kwargs)
52
+
53
+ self.cfg = cfg
54
+ self.options_cfg = options_cfg
55
+ self.selected_cfg = selected_cfg
56
+
57
+ self.qlabel = QLabel(self.selected_cfg if label is None else label)
58
+
59
+ # TODO: flexbox-like row breaking
60
+ self.row = QHBoxLayout()
61
+ self.qcheckboxes = []
62
+ for opt in self.options_cfg:
63
+ checkbox = _QCheckBox(opt)
64
+ self.qcheckboxes.append(checkbox)
65
+ self.row.addWidget(checkbox)
66
+
67
+ self.addWidget(self.qlabel)
68
+ self.addLayout(self.row)
69
+
70
+ def cfg_init(self):
71
+ val = set(self.cfg(self.selected_cfg, "QStringList"))
72
+ for box in self.qcheckboxes:
73
+ box.setChecked(box.text() in val)
74
+
75
+ def cfg_connect(self):
76
+ def update(_):
77
+ selected = [b.text() for b in self.qcheckboxes if b.isChecked()]
78
+ self.cfg.set(self.selected_cfg, selected)
79
+
80
+ for box in self.qcheckboxes:
81
+ box.toggled.connect(update)
frontends/krita/krita_diff/widgets/combo_box.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import partial
2
+ from typing import Union
3
+
4
+ from krita import QComboBox, QHBoxLayout, Qt, QValidator
5
+
6
+ from ..config import Config
7
+ from .misc import QLabel
8
+
9
+
10
+ class QOptionValidator(QValidator):
11
+ def __init__(self, opts: set, *args, **kwargs):
12
+ super(QOptionValidator, self).__init__(*args, **kwargs)
13
+ self.opts = opts
14
+
15
+ def validate(self, input, pos):
16
+ # Below validation rules make it impossible to type invalid options
17
+ if len(self.opts) < 2:
18
+ # List hasn't loaded yet
19
+ return QValidator.Intermediate, input, pos
20
+ elif input in self.opts:
21
+ return QValidator.Acceptable, input, pos
22
+ elif any(o.find(input) == 0 for o in self.opts):
23
+ return QValidator.Intermediate, input, pos
24
+ else:
25
+ return QValidator.Invalid, input, pos
26
+
27
+ def fixup(self, input):
28
+ return ""
29
+
30
+
31
+ class QComboBoxLayout(QHBoxLayout):
32
+ def __init__(
33
+ self,
34
+ cfg: Config,
35
+ options_cfg: Union[str, list],
36
+ selected_cfg: str,
37
+ label: str = None,
38
+ *args,
39
+ **kwargs
40
+ ):
41
+ """Layout for labelled QComboBox.
42
+
43
+ Args:
44
+ cfg (Config): Config to connect to.
45
+ options_cfg (Union[str, list]): Config key to read available options from or list of options.
46
+ selected_cfg (str): Config key to read/write selected option to.
47
+ label (str, optional): Label, uses `selected_cfg` if None. Defaults to None.
48
+ num_chars (int, optional): Max length of qcombo in chars. Defaults to None.
49
+ """
50
+ super(QComboBoxLayout, self).__init__(*args, **kwargs)
51
+
52
+ # Used to connect to config stored in script
53
+ self.cfg = cfg
54
+ self.options_cfg = options_cfg
55
+ self.selected_cfg = selected_cfg
56
+ self._items = set()
57
+
58
+ self.qlabel = QLabel(self.selected_cfg if label is None else label)
59
+ self.qcombo = QComboBox()
60
+ self.qcombo.view().setTextElideMode(Qt.ElideLeft)
61
+ self.qcombo.setEditable(True)
62
+ self.qcombo.setInsertPolicy(QComboBox.NoInsert)
63
+ self.qcombo.setMinimumWidth(10)
64
+
65
+ self.addWidget(self.qlabel)
66
+ self.addWidget(self.qcombo)
67
+
68
+ def cfg_init(self):
69
+ opts = sorted(
70
+ set(
71
+ self.cfg(self.options_cfg, "QStringList")
72
+ if isinstance(self.options_cfg, str)
73
+ else self.options_cfg
74
+ ),
75
+ key=str.casefold,
76
+ )
77
+
78
+ # NOTE: assumes the None option will always be labelled as "None"
79
+ if "None" in opts:
80
+ opts.remove("None")
81
+ opts.insert(0, "None")
82
+
83
+ # prevent dropdown from closing when cfg_init is called by update
84
+ if set(opts) != self._items:
85
+ self._items = set(opts)
86
+ # as using editable mode, text isn't affected by clearing options
87
+ self.qcombo.clear()
88
+ self.qcombo.addItems(opts)
89
+ self.qcombo.setValidator(QOptionValidator(self._items))
90
+
91
+ # avoid resetting the auto-completer
92
+ if self.qcombo.currentText() != self.cfg(self.selected_cfg):
93
+ self.qcombo.setEditText(self.cfg(self.selected_cfg))
94
+
95
+ def cfg_connect(self):
96
+ # Possible to get invalid by backspacing after selecting option
97
+ # but no one would do that deliberately
98
+ self.qcombo.editTextChanged.connect(partial(self.cfg.set, self.selected_cfg))
frontends/krita/krita_diff/widgets/line_edit.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import partial
2
+
3
+ from krita import QHBoxLayout, QLineEdit
4
+
5
+ from ..config import Config
6
+ from .misc import QLabel
7
+
8
+
9
+ class QLineEditLayout(QHBoxLayout):
10
+ def __init__(
11
+ self,
12
+ cfg: Config,
13
+ field_cfg: str,
14
+ label: str = None,
15
+ placeholder: str = "",
16
+ *args,
17
+ **kwargs
18
+ ):
19
+ """Layout for labelled QLineEdit.
20
+
21
+ Args:
22
+ cfg (Config): Config to connect to.
23
+ field_cfg (str): Config key to read/write value to.
24
+ label (str, optional): Label, uses `field_cfg` if None. Defaults to None.
25
+ placeholder (str, optional): Placeholder. Defaults to "".
26
+ """
27
+ super(QLineEditLayout, self).__init__(*args, **kwargs)
28
+
29
+ self.cfg = cfg
30
+ self.field_cfg = field_cfg
31
+
32
+ self.qedit = QLineEdit()
33
+ self.qedit.setPlaceholderText(placeholder)
34
+ self.addWidget(QLabel(field_cfg if label is None else label))
35
+ self.addWidget(self.qedit)
36
+
37
+ def cfg_init(self):
38
+ # NOTE: update timer -> cfg_init, setText seems to reset cursor position so we prevent it
39
+ val = self.cfg(self.field_cfg, str)
40
+ if self.qedit.text() != val:
41
+ self.qedit.setText(val)
42
+
43
+ def cfg_connect(self):
44
+ self.qedit.textChanged.connect(partial(self.cfg.set, self.field_cfg))
frontends/krita/krita_diff/widgets/misc.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from krita import QLabel as _QLabel
2
+ from krita import Qt
3
+
4
+
5
+ class QLabel(_QLabel):
6
+ """QLabel with overwritten default behaviours."""
7
+
8
+ def __init__(self, *args, **kwargs):
9
+ super(QLabel, self).__init__(*args, **kwargs)
10
+
11
+ self.setOpenExternalLinks(True)
12
+ self.setWordWrap(True)
13
+ self.setTextFormat(Qt.TextFormat.RichText)
frontends/krita/krita_diff/widgets/prompt.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from krita import QPlainTextEdit, QSizePolicy, QVBoxLayout
2
+
3
+ from ..config import Config
4
+
5
+
6
+ class QPromptEdit(QPlainTextEdit):
7
+ def __init__(self, placeholder="Enter prompt...", num_lines=5, *args, **kwargs):
8
+ super(QPromptEdit, self).__init__(*args, **kwargs)
9
+ self.setPlaceholderText(placeholder)
10
+ self.setFixedHeight(self.fontMetrics().lineSpacing() * num_lines)
11
+ self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Maximum)
12
+
13
+
14
+ class QPromptLayout(QVBoxLayout):
15
+ prompt_label: str = "Prompt:"
16
+ neg_prompt_label: str = "Negative prompt:"
17
+
18
+ def __init__(
19
+ self, cfg: Config, prompt_cfg: str, neg_prompt_cfg: str, *args, **kwargs
20
+ ):
21
+ """Layout for prompt and negative prompt.
22
+
23
+ Args:
24
+ cfg (Config): Config to connect to.
25
+ prompt_cfg (str): Config key to read/write prompt to.
26
+ neg_prompt_cfg (str): Config key to read/write negative prompt to.
27
+ """
28
+ super(QPromptLayout, self).__init__(*args, **kwargs)
29
+
30
+ # Used to connect to config stored in script
31
+ self.cfg = cfg
32
+ self.prompt_cfg = prompt_cfg
33
+ self.neg_prompt_cfg = neg_prompt_cfg
34
+
35
+ self.qedit_prompt = QPromptEdit(placeholder=self.prompt_label)
36
+ self.qedit_neg_prompt = QPromptEdit(placeholder=self.neg_prompt_label)
37
+
38
+ self.addWidget(self.qedit_prompt)
39
+ self.addWidget(self.qedit_neg_prompt)
40
+
41
+ def cfg_init(self):
42
+ # NOTE: update timer -> cfg_init, setText seems to reset cursor position so we prevent it
43
+ prompt = self.cfg(self.prompt_cfg, str)
44
+ neg_prompt = self.cfg(self.neg_prompt_cfg, str)
45
+ if self.qedit_prompt.toPlainText() != prompt:
46
+ self.qedit_prompt.setPlainText(prompt)
47
+ if self.qedit_neg_prompt.toPlainText() != neg_prompt:
48
+ self.qedit_neg_prompt.setPlainText(neg_prompt)
49
+
50
+ def cfg_connect(self):
51
+ self.qedit_prompt.textChanged.connect(
52
+ lambda: self.cfg.set(self.prompt_cfg, self.qedit_prompt.toPlainText())
53
+ )
54
+ self.qedit_neg_prompt.textChanged.connect(
55
+ lambda: self.cfg.set(
56
+ self.neg_prompt_cfg, self.qedit_neg_prompt.toPlainText()
57
+ )
58
+ )
frontends/krita/krita_diff/widgets/spin_box.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import partial
2
+ from math import isclose
3
+ from typing import Union
4
+
5
+ from krita import QDoubleSpinBox, QHBoxLayout, QSpinBox
6
+
7
+ from ..config import Config
8
+ from .misc import QLabel
9
+
10
+
11
+ class QSpinBoxLayout(QHBoxLayout):
12
+ def __init__(
13
+ self,
14
+ cfg: Config,
15
+ field_cfg: str,
16
+ label: str = None,
17
+ min: Union[int, float] = 0.0,
18
+ max: Union[int, float] = 1.0,
19
+ step: Union[int, float] = 0.1,
20
+ *args,
21
+ **kwargs
22
+ ):
23
+ """Layout for labelled QSpinBox/QDoubleSpinBox.
24
+
25
+ Will infer which to use based on type of min, max and step.
26
+
27
+ Args:
28
+ cfg (Config): Config to connect to.
29
+ field_cfg (str): Config key to read/write value to.
30
+ label (str, optional): Label, uses `field_cfg` if None. Defaults to None.
31
+ min (Union[int, float], optional): Min value. Defaults to 0.0.
32
+ max (Union[int, float], optional): Max value. Defaults to 1.0.
33
+ step (Union[int, float], optional): Value step. Defaults to 0.1.
34
+ """
35
+ super(QSpinBoxLayout, self).__init__(*args, **kwargs)
36
+
37
+ self.cfg = cfg
38
+ self.field_cfg = field_cfg
39
+
40
+ self.qlabel = QLabel(field_cfg if label is None else label)
41
+
42
+ is_integer = (
43
+ float(step).is_integer()
44
+ and float(min).is_integer()
45
+ and float(max).is_integer()
46
+ )
47
+ self.cast = int if is_integer else float
48
+
49
+ self.qspin = QSpinBox() if is_integer else QDoubleSpinBox()
50
+ self.qspin.setMinimum(self.cast(min))
51
+ self.qspin.setMaximum(self.cast(max))
52
+ self.qspin.setSingleStep(self.cast(step))
53
+ self.addWidget(self.qlabel)
54
+ self.addWidget(self.qspin)
55
+
56
+ def cfg_init(self):
57
+ val = self.cfg(self.field_cfg, self.cast)
58
+ cur = self.qspin.value()
59
+ # prevent cursor from jumping when cfg_init is called by update
60
+ if not isclose(val, cur):
61
+ self.qspin.setValue(self.cfg(self.field_cfg, self.cast))
62
+
63
+ def cfg_connect(self):
64
+ self.qspin.valueChanged.connect(partial(self.cfg.set, self.field_cfg))
frontends/krita/krita_diff/widgets/status_bar.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from ..defaults import (
2
+ STATE_DONE,
3
+ STATE_INIT,
4
+ STATE_INTERRUPT,
5
+ STATE_READY,
6
+ STATE_URLERROR,
7
+ )
8
+ from .misc import QLabel
9
+
10
+
11
+ class StatusBar(QLabel):
12
+ def __init__(self, *args, **kwargs):
13
+ super(StatusBar, self).__init__(*args, **kwargs)
14
+ self.set_status(STATE_INIT)
15
+
16
+ def set_status(self, s):
17
+ if s == STATE_READY and STATE_URLERROR not in self.text():
18
+ return
19
+ if s == STATE_DONE and STATE_INTERRUPT == self.text():
20
+ return
21
+
22
+ self.setText(f"<b>Status:</b> {s}")
frontends/krita/krita_diff/widgets/tips.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List
2
+
3
+ from krita import QVBoxLayout
4
+
5
+ from .misc import QLabel
6
+
7
+
8
+ class TipsLayout(QVBoxLayout):
9
+ def __init__(self, tips: List[str], prefix="<em>Tip:</em> ", *args, **kwargs):
10
+ super(TipsLayout, self).__init__(*args, **kwargs)
11
+
12
+ self.tips = [QLabel(prefix + t) for t in tips]
13
+ for t in self.tips:
14
+ self.addWidget(t)
15
+
16
+ def setVisible(self, visible: bool):
17
+ for t in self.tips:
18
+ t.setVisible(visible)
install.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from launch import git, run
5
+
6
+ REPO_LOCATION = Path(__file__).parent
7
+ auto_update = os.environ.get("AUTO_UPDATE", "True").lower() in {"true", "yes"}
8
+
9
+ if auto_update:
10
+ print("[auto-sd-paint-ext] Attempting auto-update...")
11
+
12
+ try:
13
+ # current_hash = run(
14
+ # f'"{git}" -C {REPO_LOCATION} rev-parse HEAD',
15
+ # "[auto-sd-paint-ext] Get commit hash.",
16
+ # ).strip()
17
+
18
+ run(f'"{git}" -C "{REPO_LOCATION}" fetch', "[auto-sd-paint-ext] Fetch upstream.")
19
+
20
+ run(f'"{git}" -C "{REPO_LOCATION}" pull', "[auto-sd-paint-ext] Pull upstream.")
21
+ except Exception as e:
22
+ print("[auto-sd-paint-ext] Auto-update failed:")
23
+ print(e)
24
+ print("[auto-sd-paint-ext] Ensure git was used to install extension.")
25
+
26
+ # NOTE: if we ever get dependencies, we can install them here.
scripts/main.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from pathlib import Path
3
+
4
+ import backend
5
+ import gradio as gr
6
+ from backend.app import app_encryption_middleware
7
+ from backend.config import LOGGER_NAME, ROUTE_PREFIX, SCRIPT_ID, SCRIPT_NAME
8
+ from backend.utils import get_encrypt_key
9
+ from fastapi import FastAPI
10
+ from modules import script_callbacks, scripts, shared
11
+
12
+ PLUGIN_LOCATION = Path(scripts.basedir()) / "frontends" / "krita"
13
+
14
+
15
+ class BackendScript(scripts.Script):
16
+ def title(self):
17
+ return SCRIPT_NAME
18
+
19
+ def show(self, is_img2img):
20
+ return scripts.AlwaysVisible
21
+
22
+ def ui(self, is_img2img):
23
+ return []
24
+
25
+ def process(self, _):
26
+ pass
27
+
28
+
29
+ started = False
30
+
31
+
32
+ def on_app_started(demo: gr.Blocks, app: FastAPI):
33
+ # NOTE: There is currently a glitch where the on_app_started() callback is called twice
34
+ # Surprisingly, it only breaks the encryption middleware and causes duplicate logs
35
+ # Below is a workaround to fix said issues
36
+ # Downside is that for now, restarting Gradio via the webUI will just break the extension
37
+ global started
38
+ if started:
39
+ return
40
+ started = True
41
+
42
+ logger = logging.getLogger(LOGGER_NAME)
43
+ logger.setLevel(logging.INFO)
44
+ handler = logging.StreamHandler()
45
+ handler.setFormatter(
46
+ logging.Formatter(
47
+ fmt="%(name)s:%(levelname)s: %(message)s",
48
+ datefmt="%Y-%m-%d %H:%M:%S",
49
+ )
50
+ )
51
+ logger.addHandler(handler)
52
+
53
+ if shared.cmd_opts.api:
54
+ app.include_router(backend.router, prefix=ROUTE_PREFIX, tags=[SCRIPT_NAME])
55
+ app.middleware("http")(app_encryption_middleware)
56
+ # on first run, this creates a key file
57
+ get_encrypt_key()
58
+ if not shared.cmd_opts.listen:
59
+ logger.info(
60
+ "Add --listen to COMMANDLINE_ARGS to enable usage as a remote backend."
61
+ )
62
+ else:
63
+ logger.warning("COMMANDLINE_ARGS does not contain --api, API won't be mounted.")
64
+ # if you wanted to do anything massive to the UI, you could modify demo, but why?
65
+
66
+
67
+ def on_ui_settings():
68
+ # hook to add our own settings to the settings tab
69
+ pass
70
+
71
+
72
+ def krita_help(folder):
73
+ folder = "<path_to_pykrita>" if not folder else folder
74
+ return f"""
75
+ Search for "Command Prompt" in the Start Menu, right-click and click "Run as Administrator...", paste the follow commands and hit Enter:
76
+ ```bat
77
+ mklink /j "{folder}\\krita_diff" "{(PLUGIN_LOCATION / 'krita_diff').resolve()}"
78
+ mklink "{folder}\\krita_diff.desktop" "{(PLUGIN_LOCATION / 'krita_diff.desktop').resolve()}"
79
+ ```
80
+
81
+ Linux command:
82
+ ```sh
83
+ ln -s "{(PLUGIN_LOCATION / 'krita_diff').resolve()}" "{folder}/krita_diff"
84
+ ln -s "{(PLUGIN_LOCATION / 'krita_diff.desktop').resolve()}" "{folder}/krita_diff.desktop"
85
+ ```
86
+ """
87
+
88
+
89
+ def on_ui_tabs():
90
+ # hook to create our own UI tab
91
+ with gr.Blocks(analytics_enabled=False) as interface:
92
+ gr.Markdown(
93
+ """
94
+ ### Generate Krita Plugin Symlink Command
95
+
96
+ 1. Launch Krita.
97
+ 2. On the menubar, go to `Settings > Manage Resources...`.
98
+ 3. In the window that appears, click `Open Resource Folder`.
99
+ 4. In the file explorer that appears, look for a folder called `pykrita` or create it.
100
+ 5. Enter the `pykrita` folder and copy the folder location from the address bar.
101
+ 6. Paste the folder location below.
102
+ """
103
+ )
104
+ folder = gr.Textbox(
105
+ placeholder="C:\\\\...\\pykrita", label="Pykrita Folder Location", lines=1
106
+ )
107
+ out = gr.Markdown(krita_help(""))
108
+ folder.change(krita_help, folder, out)
109
+ gr.Markdown(
110
+ """
111
+ **NOTE**: Symlinks will break if you move or rename the repository or any
112
+ of its parent folders or otherwise change the path such that the symlink
113
+ becomes invalid. In which case, repeat the above steps with the new `pykrita`
114
+ folder location and (auto-detected) repository location.
115
+
116
+ **NOTE**: Ensure `webui-user.bat`/`webui-user.sh` contains `--api` in `COMMANDLINE_ARGS`!
117
+ """
118
+ )
119
+ gr.Markdown(
120
+ """
121
+ ### Enabling the Krita Plugin
122
+
123
+ 1. Restart Krita.
124
+ 2. On the menubar, go to `Settings > Configure Krita...`
125
+ 3. On the left sidebar, go to `Python Plugin Manager`.
126
+ 4. Look for `Stable Diffusion Plugin` and tick the checkbox.
127
+ 5. Restart Krita again for changes to take effect.
128
+
129
+ The `SD Plugin` docked window should appear on the left of the Krita window. If it does not, look on the menubar under `Settings > Dockers` for `SD Plugin`.
130
+
131
+ ### Next Steps
132
+
133
+ - [Troubleshooting](https://github.com/Interpause/auto-sd-paint-ext/wiki/Troubleshooting)
134
+ - [Update Guide](https://github.com/Interpause/auto-sd-paint-ext/wiki/Update-Guide)
135
+ - [Usage Guide](https://github.com/Interpause/auto-sd-paint-ext/wiki/Usage-Guide)
136
+ """
137
+ )
138
+ gr.Markdown("TODO: Control/status panel")
139
+
140
+ return [(interface, "auto-sd-paint-ext Guide/Panel", SCRIPT_ID)]
141
+
142
+
143
+ # NOTE: see modules/script_callbacks.py for all callbacks
144
+ script_callbacks.on_app_started(on_app_started)
145
+ script_callbacks.on_ui_tabs(on_ui_tabs)
146
+ script_callbacks.on_ui_settings(on_ui_settings)