diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..356a0df --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Thought Stream, LLC dba Bluescape. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e0a49c3..131bc20 100644 --- a/README.md +++ b/README.md @@ -1 +1,109 @@ -# sd-webui-bluescape \ No newline at end of file +# Bluescape Extension for Stable Diffusion WebUI + +Upload generated images to a Bluescape workspace for review and collaborate with others. + + + + + +## Installation + +1. Open "Extensions" tab. +2. Open "Install from URL" tab in the tab. +3. Enter `https://github.com/Bluescape/sd-webui-bluescape` to "URL for extension's git repository". +4. Press "Install" button. +5. Wait for 5 seconds, and you will see the message "Installed into stable-diffusion-webui\extensions\sd-webui-bluescape. Use Installed tab to restart". +6. Go to "Installed" tab, click "Check for updates", and then click "Apply and restart UI". (The next time you can also use these buttons to update the extension.) +7. Completely restart A1111 webui including the backend through your terminal + +_Note: Currently the Bluescape extension only supports the standard A1111 domain / port `(localhost:7860)`._ + +## Getting started + +Get started using the Bluescape extension: + +1. **Open Extension**: Open the Bluescape tab on the A1111 webui +2. **Register**: If you haven’t done so yet, register for a free account by clicking the button +3. **Login**: Log into your account +4. **Select Workspace**: Choose the workspace where you’d like to upload generated images +5. **Enable Upload Option**: Navigate to either the ‘txt2img’ or ‘img2img’ tab and select the “Upload results to Bluescape” option +6. **Generate**: Generate images +7. **Review**: Open your workspace to review, curate and collaborate on the generated images + +_Note: The free account has a limit on the number of workspaces and amount of data that can be stored in the workspace. Additional resources are available through paid plans._ + + + +## Configuration options + +These are the defaults for the extension configuration options: + + + +### Include extended generation data in workspace + +By default the extension adds a text field to the workspace with the basic generation data: + + + +Enabling extended generation data adds another text field with additional generation information: + + + +_Note: The default generation data added to the Bluescape workspace can be copied back to Automatic1111 and re-applied through the prompt._ + +### Include source image in workspace (img2img) + +Whether or not to include the source image in the workspace when using img2img: + + + +### Include image mask in workspace (img2img) + +Whether or not to include the image mask in the workspace when using img2img with masks. + + + +### Scale images to standard size (1000x1000) in workspace + +Whether to scale images to standard Bluescape image size of 1000x1000 in the workspace. + +Note, that this only applies scaling at the workspace level and does not actually change the image data. You can always resize to original size in the workspace as well: + + + + + +This is a good option to enable when you have a lot of images in the workspace from different sources and it works well when using images between 500-1000. However, if you work on large images of different aspect ratios it is best to turn this off. + +### Store metadata in image object within workspace + +When this is enabled the generation data and extended generation data are stored as metadata into the Bluescape workspace image elements. This is in anticipation of future Bluescape functionality that makes it easier to copy the generation data back to A1111 and other workflow improvements as well + +You can disable this but it may limit the uploaded images from taking advantage of future A1111 related improvements in Bluescape. + +### Send extension usage analytics + +Whether to send analytics events about user registration, login and upload events. This helps Bluescape assess the amount of use the extension. + +You can disable this if you'd like. + +## Future aspirations + +- Upload img2img masks to the workspace +- Upload ControlNet source image and mask to the workspace +- Image element context menu item to copy the generation data in A1111 webui compatible format +- A1111 running in non-default domain/port +- Retrigger image generation with same or modified parameters in A1111 directly from the workspace +- Additional layout options +- Browse and upload existing generated images from A1111 + +## Feedback and more information + +Feel free to send us any feedback you may have either through Github or +[Bluescape community forums](https://community.bluescape.com/). + +The extension is open source and licensed under [MIT License](LICENSE). + +If you are interested in developing on the Bluescape platform, please take a look at the [developer documentation](https://api.apps.us.bluescape.com/docs/). + diff --git a/bs/__init__.py b/bs/__init__.py new file mode 100644 index 0000000..07f39e0 --- /dev/null +++ b/bs/__init__.py @@ -0,0 +1,26 @@ +# +# MIT License +# +# Copyright (c) 2023 Thought Stream, LLC dba Bluescape. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +__version__ = "2.11.2" + +from .extension import BluescapeUploadManager \ No newline at end of file diff --git a/bs/analytics.py b/bs/analytics.py new file mode 100644 index 0000000..27a47f2 --- /dev/null +++ b/bs/analytics.py @@ -0,0 +1,139 @@ +# +# MIT License +# +# Copyright (c) 2023 Thought Stream, LLC dba Bluescape. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +from datetime import datetime +from .state_manager import StateManager +import requests +from .config import Config + +class AnalyticsEvent: + componentId = str + category = str + type = str + containsPII = bool + containsConfidential = bool + date = datetime + organizationId = str + workspaceId = str + actorType = str + data = object + +class Analytics: + + category = "AI" + componentId = "automatic1111-extension" + + def __init__(self, state: StateManager): + self.state = state + + # Public + + def send_user_attempting_registration_event(self, registration_attempt_id): + e = AnalyticsEvent() + e.category = self.category + e.componentId = self.componentId + e.type = "UserAttemptingRegistration" + e.containsPII = False + e.containsConfidential = False + e.workspaceId = None + e.date = datetime.now() + e.data = { + "registrationAttemptId": registration_attempt_id, + "a1111Version": self.state.a1111_version, + "extensionVersion": self.state.extension_version + } + + self.send_event(e, None) + + def send_user_logged_in_event(self, token, user_id): + e = AnalyticsEvent() + e.category = self.category + e.componentId = self.componentId + e.type = "UserLoggedIn" + e.containsPII = False + e.containsConfidential = False + e.workspaceId = None + e.date = datetime.now() + e.data = { + "userId": user_id, + "a1111Version": self.state.a1111_version, + "extensionVersion": self.state.extension_version + } + + self.send_event(e, token) + + def send_uploaded_generated_images_event(self, token, workspace_id, images_number, user_id): + e = AnalyticsEvent() + e.category = self.category + e.componentId = self.componentId + e.type = "UserUploadedGeneratedImages" + e.containsPII = False + e.containsConfidential = False + e.workspaceId = workspace_id + e.date = datetime.now() + e.data = { + "imageCount": images_number, + "userId": user_id, + "a1111Version": self.state.a1111_version, + "extensionVersion": self.state.extension_version + } + + self.send_event(e, token) + + # Private + + def send_event(self, e: AnalyticsEvent, token): + + if self.state.enable_analytics: + body = { + 'events': [ + { + 'category': e.category, + 'componentId': e.componentId, + 'type': e.type, + 'containsPII': e.containsPII, + 'containsConfidential': e.containsConfidential, + 'date': datetime.utcnow().isoformat() + 'Z', + 'data': e.data, + } + ] + } + + if e.workspaceId is not None: + body['events'][0]['workspaceId'] = e.workspaceId + + url = f'{Config.analytics_base_domain}/api/v3/collect' + response = requests.post(url, json = body, headers = self.get_headers(token)) + if response.status_code != 200: + print("Analytics response: " + str(response.text)) + + def get_headers(self, token): + if token is not None: + return { + #'Authorization': f'Bearer {token}', + 'Content-type': 'application/json' + } + + return { + 'Content-type': 'application/json' + } diff --git a/bs/bluescape_api.py b/bs/bluescape_api.py new file mode 100644 index 0000000..16e37df --- /dev/null +++ b/bs/bluescape_api.py @@ -0,0 +1,619 @@ +# +# MIT License +# +# Copyright (c) 2023 Thought Stream, LLC dba Bluescape. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +import re +from .expired_token_exception import ExpiredTokenException +from .config import Config +from typing import Tuple +import requests +import json + +default_timeout = 30 + +def bs_find_space(token, workspace_id, bounding_box: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]: + + x, y, width, height = bounding_box + + bs_api_url = f'{Config.api_base_domain}/v3/workspaces/{workspace_id}/findAvailableArea' + + body = { + "direction": "Right", + "proposedArea": { + "x": x, + "y": y, + "width": width, + "height": height + } + } + + response = requests.post(bs_api_url, json = body, headers = get_headers(token), timeout = default_timeout) + + if response.status_code == 200: + + result = response.json() + x = int(result["x"]) + y = int(result["y"]) + width = int(result["width"]) + height = int(result["height"]) + + return (x, y, width, height) + + elif response.status_code == 401: + raise ExpiredTokenException + +def bs_create_zygote_at(token, workspace_id, filename, x, y, width, height, traits): + + bs_api_url = f'{Config.api_base_domain}/v3/workspaces/{workspace_id}/elements' + + body = { + 'type': 'Image', + 'imageFormat': 'png', + 'title': filename, + 'filename': filename, + 'width': width, + 'height': height, + 'transform': { + 'x': x, + 'y': y + }, + 'traits': { + 'content': { + + } + } + } + + for k, v in traits.items(): + body['traits']['content'][k] = v + + response = requests.post(bs_api_url, json = body, headers = get_headers(token), timeout = default_timeout) + + return response.text + +def bs_upload_asset(zr, buffer): + + body = { + 'key': zr['data']['content']['fields']['key'], + 'bucket': zr['data']['content']['fields']['bucket'], + 'X-Amz-Algorithm': zr['data']['content']['fields']['X-Amz-Algorithm'], + 'X-Amz-Credential': zr['data']['content']['fields']['X-Amz-Credential'], + 'X-Amz-Date': zr['data']['content']['fields']['X-Amz-Date'], + 'Policy': zr['data']['content']['fields']['Policy'], + 'X-Amz-Signature': zr['data']['content']['fields']['X-Amz-Signature'], + } + + files = { 'file': buffer} + + url = zr['data']['content']['url'] + response = requests.post(url, data = body, files = files, timeout = default_timeout) + + return response.text + +def bs_finish_asset(token, workspace_id, upload_id): + + bs_elementary_api_url = f'{Config.api_base_domain}/v3/workspaces/{workspace_id}/assets/uploads/{upload_id}' + + requests.put(bs_elementary_api_url, headers = get_headers(token), json = {}, timeout = default_timeout) + +def bs_upload_image_at(token, workspace_id, buffer, filename, bounding_box: Tuple[int, int, int, int], traits): + + x, y, width, height = bounding_box + + zygote_response = bs_create_zygote_at(token, workspace_id, filename, x, y, width,height, traits) + zygote = json.loads(zygote_response) + bs_upload_asset(zygote, buffer) + bs_finish_asset(token, workspace_id, zygote['data']['content']['uploadId']) + +def bs_create_canvas_at(token, workspace_id, title, bounding_box: Tuple[int, int, int, int], traits): + + x, y, width, height = bounding_box + + body = { + 'type': 'Canvas', + 'name': title, + 'style': { + 'width': width, + 'height': height, + 'fillColor': { + 'r': 255, + 'g': 255, + 'b': 255, + 'a': 1 + }, + 'borderColor': { + 'r': 255, + 'g': 255, + 'b': 255, + 'a': 1 + }, + 'showName': True + }, + 'transform': { + 'x': x, + 'y': y + }, + 'traits': { + 'content': { + + } + } + } + + for k, v in traits.items(): + body['traits']['content'][k] = v + + url = f'{Config.api_base_domain}/v3/workspaces/{workspace_id}/elements' + + response = requests.post(url, json = body, headers = get_headers(token), timeout = default_timeout) + + response_info = json.loads(response.text) + + return response_info['data']['id'] + +def bs_create_text_with_body(token, workspace_id, body): + + url = f'{Config.api_base_domain}/v3/workspaces/{workspace_id}/elements' + + response = requests.post(url, json = body, headers = get_headers(token), timeout = default_timeout) + response_info = json.loads(response.text) + + return response_info['data']['id'] + +def bs_create_top_title(token, workspace_id, location: Tuple[int, int, int], text): + + title = " | " + text + + body = { + "type": "Text", + "blocks": [ + { + "block": { + "content": [ + { + "text": "Automatic1111" + }, + { + "span": { + "color": { #charcoal50 + "r": 145, + "g": 149, + "b": 151, + "a": 1 + }, + "text": title + } + } + ] + } + } + ], + "style": { + "fontFamily": "Source_Sans_Pro", + "fontSize": 63, + "width": location[2], + "backgroundColor": { + "r": 255, + "g": 255, + "b": 255, + "a": 0 + }, + "color": { #charcoal100 + "r": 46, + "g": 54, + "b": 61, + "a": 1 + }, + "verticalAlign": "top" + }, + "transform": { + "x": location[0], + "y": location[1] + } + } + + return bs_create_text_with_body(token, workspace_id, body) + +def bs_create_extended_data(token, workspace_id, location: Tuple[int, int, int], extended_generation_data): + + content = [] + for key_value_pair in extended_generation_data: + key = key_value_pair[0] + value = key_value_pair[1] + content.append( + { + "span": { + "fontWeight": "bold", + "text": str(key) + ": " + } + } + ) + content.append( + { + "span": { + "text": str(value) + ", " + } + } + ) + + body = { + "type": "Text", + "blocks": [ + { + "block": { + "content": content + } + } + ], + "style": { + "fontFamily": "Source_Sans_Pro", + "fontSize": 32, + "width": location[2], + "backgroundColor": { #bluescape20 + "r": 204, + "g": 226, + "b": 255, + "a": 1 + }, + "color": { #charcoal90 + "r": 67, + "g": 74, + "b": 80, + "a": 1 + }, + "verticalAlign": "top" + }, + "transform": { + "x": location[0], + "y": location[1] + } + } + + return bs_create_text_with_body(token, workspace_id, body) + +def bs_create_generation_data(token, workspace_id, location: Tuple[int, int, int], infotext): + + infos = infotext.split("\n") + + blocks = [] + + # Example with prompt + negative prompt: + # + # Ellen Ripley from Alien movie, astronaut, [perfect symmetric] helmet on, visor closed, looking up, action pose, standing on background of Nostromo space shuttle [intricate] interiors , sci-fi + # Negative prompt: disfigured, bad + # Steps: 30, Sampler: Euler a, CFG scale: 7, Seed: 889387345, Face restoration: CodeFormer, Size: 512x512, Model hash: ad2a33c361, Model: v2-1_768-ema-pruned, Version: v1.3.1 + + # Example without negative prompt: + # + # Ellen Ripley from Alien movie, astronaut, [perfect symmetric] helmet on, visor closed, looking up, action pose, standing on background of Nostromo space shuttle [intricate] interiors , sci-fi + # Steps: 30, Sampler: Euler a, CFG scale: 7, Seed: 889387345, Face restoration: CodeFormer, Size: 512x512, Model hash: ad2a33c361, Model: v2-1_768-ema-pruned, Version: v1.3.1 + + # Example without prompt, but with negative prompt: + # + # Negative prompt: disfigured, bad + # Steps: 30, Sampler: Euler a, CFG scale: 7, Seed: 889387345, Face restoration: CodeFormer, Size: 512x512, Model hash: ad2a33c361, Model: v2-1_768-ema-pruned, Version: v1.3.1 + + # Example without prompt + without negative prompt: + # + # Steps: 30, Sampler: Euler a, CFG scale: 7, Seed: 889387345, Face restoration: CodeFormer, Size: 512x512, Model hash: ad2a33c361, Model: v2-1_768-ema-pruned, Version: v1.3.1 + + # This is a bit hacky, but we'll check if the row starts with "Negative prompt:" or "Steps:" and then turn + # on styling for that row (keys to be bolded, values not) + + for info in infos: + content = [] + if info.startswith("Negative prompt:"): + key, value = info.split(':', 1) + content.append( + { + "span": { + "fontWeight": "bold", + "text": str(key) + ": " + } + } + ) + content.append( + { + "span": { + "text": str(value) + } + } + ) + elif info.startswith("Steps:"): + try: + # Regex pattern to match either key: "value with, commas" or key: value + pattern = re.compile(r'([^:]+): ("[^"]*"|[^,]*),?') + + # Find all matches in the string + matches = pattern.findall(info) + + # Convert matches to a dictionary + key_value_pairs = {key.strip(): value.strip(' "') + for key, value in matches} + + for key, value in key_value_pairs.items(): + content.append( + { + "span": { + "fontWeight": "bold", + "text": str(key) + ": " + } + } + ) + content.append( + { + "span": { + "text": str(value) + ", " + } + } + ) + except Exception as e: + print("Failed to format generation data:") + print(e) + print("Falling back to unformatted string") + content.append( + { + "span": { + "text": str(info) + } + } + ) + + else: + content.append( + { + "span": { + "text": str(info) + } + } + ) + + blocks.append( + { + "block": { + "content": content + } + } + ) + + + body = { + "type": "Text", + "blocks": blocks, + "style": { + "fontFamily": "Source_Sans_Pro", + "fontSize": 32, + "width": location[2], + "backgroundColor": { #bluescape20 + "r": 204, + "g": 226, + "b": 255, + "a": 1 + }, + "color": { #charcoal90 + "r": 67, + "g": 74, + "b": 80, + "a": 1 + }, + "verticalAlign": "top" + }, + "transform": { + "x": location[0], + "y": location[1] + } + } + + return bs_create_text_with_body(token, workspace_id, body) + +def bs_create_seed(token, workspace_id, location: Tuple[int, int, int], seed, subseed): + + body = { + "type": "Text", + "blocks": [ + { + "block": { + "content": [ + { + "span": { + "fontWeight": "bold", + "text": "Seed: " + } + }, + { + "span": { + "text": str(seed) + } + }, + { + "span": { + "fontStyle": "italic", + "text": " (sub: " + str(subseed) + ")" + } + } + ] + } + } + ], + "style": { + "fontFamily": "Source_Sans_Pro", + "fontSize": 24, + "width": location[2], + "backgroundColor": { #bluescape20 + "r": 204, + "g": 226, + "b": 255, + "a": 1 + }, + "color": { #charcoal90 + "r": 67, + "g": 74, + "b": 80, + "a": 1 + }, + "verticalAlign": "top" + }, + "transform": { + "x": location[0], + "y": location[1] + } + } + + return bs_create_text_with_body(token, workspace_id, body) + + + +def bs_create_generation_label(token, workspace_id, location: Tuple[int, int, int], text): + + body = { + "type": "Text", + "blocks": [ + { + "block": { + "content": [ + { + "span": { + "fontWeight": "bold", + "text": text + } + } + ] + } + } + ], + "style": { + "fontFamily": "Source_Sans_Pro", + "fontSize": 32, + "width": location[2], + "backgroundColor": { + "r": 255, + "g": 255, + "b": 255, + "a": 0 + }, + "color": { #charcoal90 + "r": 67, + "g": 74, + "b": 80, + "a": 1 + }, + "verticalAlign": "top" + }, + "transform": { + "x": location[0], + "y": location[1] + } + } + + return bs_create_text_with_body(token, workspace_id, body) + + +def bs_create_label(token, workspace_id, location: Tuple[int, int, int], text): + + body = { + "type": "Text", + "blocks": [ + { + "block": { + "content": [ + { + "span": { + "fontWeight": "bold", + "text": text + } + } + ] + } + } + ], + "style": { + "fontFamily": "Source_Sans_Pro", + "fontSize": 24, + "width": location[2], + "backgroundColor": { #cheeseSoft + "r": 254, + "g": 240, + "b": 159, + "a": 1 + }, + "color": { #charcoal90 + "r": 67, + "g": 74, + "b": 80, + "a": 1 + }, + "verticalAlign": "top" + }, + "transform": { + "x": location[0], + "y": location[1] + } + } + + return bs_create_text_with_body(token, workspace_id, body) + +def bs_get_all_workspaces(token): + has_next_page = True + cursor = None + workspaces = [] + i = 1 + + while has_next_page: + (current_page, next) = bs_get_workspaces(token, cursor) + workspaces = workspaces + current_page + cursor = next + # Limit for 3 requests for now + print(f"Loading workspaces... {str(i)} of 3") + i = i + 1 + has_next_page = next is not None and i < 4 + + return workspaces + +def bs_get_workspaces(token, cursor = None): + url = f'{Config.api_base_domain}/v3/users/me/workspaces?pageSize=100&includeCount=true&filterBy=associatedWorkspaces eq false&orderBy=contentUpdatedAt desc' + + if (cursor is not None): + url = f'{Config.api_base_domain}/v3/users/me/workspaces?cursor={cursor}' + + response = requests.get(url, headers = get_headers(token), timeout = default_timeout) + if response.status_code == 200: + response_info = json.loads(response.text) + return (response_info['workspaces'], response_info['next']) + elif response.status_code == 401: + raise ExpiredTokenException + +def bs_get_user_id(token): + url = f'{Config.api_base_domain}/v3/users/me' + + response = requests.get(url, headers = get_headers(token)) + + if response.status_code == 200: + response_info = json.loads(response.text) + return response_info["id"] + + print("Error: " + response.text) + +def get_headers(token): + return { + 'Authorization': f'Bearer {token}', + 'Content-type': 'application/json' + } diff --git a/bs/bluescape_layout.py b/bs/bluescape_layout.py new file mode 100644 index 0000000..b82debf --- /dev/null +++ b/bs/bluescape_layout.py @@ -0,0 +1,206 @@ +# +# MIT License +# +# Copyright (c) 2023 Thought Stream, LLC dba Bluescape. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +from typing import List, Tuple +import math + +class BluescapeLayout: + + # Padding used for title positioning within the canvas + top_title_padding_left = 100 + top_title_padding_top = 90 + + # Padding used for infotext padding from the bottom of the canvas. + # If verbose mode enabled the infotext_top_from_bottom is adjusted + infotext_padding_left = 100 + infotext_top_from_bottom = 250 + + # Padding used for infobar padding at the bottom of the canvas. + bottom_infobar_padding_left = 100 + bottom_infobar_top_from_bottom = 200 + + # Used to make space for seed info panel below each image. + vertical_seed_margin = 50 + margin = 50 # otherwise + + canvas_padding = (400, 500, 50, 50) + + def __init__(self, num_images: int, image_size: Tuple[int, int], verbose_mode: bool): + min_num_columns = 3 if image_size[0] >= 768 else 4 + num_suggested_columns = math.floor(math.sqrt(num_images)) + num_columns = num_suggested_columns if num_suggested_columns > min_num_columns else min_num_columns + + if (verbose_mode): + self.infotext_top_from_bottom = 500 + self.canvas_padding = (400, 750, 50, 50) + + self.canvas_padding_top = self.canvas_padding[0] + self.canvas_padding_left = self.canvas_padding[2] + + self.image_grid_layout, self.image_grid_bounding_box = self._calculate_image_grid_layout(num_images, num_columns, image_size, self.margin) + self.canvas_bounding_box = self._create_canvas_bounding_box_at_origin(self.canvas_padding) + + # Translate the image grid to origin + padding + self.image_grid_layout = self._translate_image_grid_layout(self.canvas_padding_left, self.canvas_padding_top) + + self.image_height = image_size[1] + + # Public interface + + def get_image_grid_layout(self) -> Tuple[List[Tuple[int, int]]]: + return self.image_grid_layout + + def get_canvas_bounding_box(self) -> Tuple[int, int, int, int]: + return self.canvas_bounding_box + + def get_label_grid_layout(self) -> Tuple[List[Tuple[int, int]]]: + return self._get_seed_grid_layout() + + def translate(self, target_bounding_box: Tuple[int, int, int, int]): + new_x, new_y, _, _ = target_bounding_box + self.canvas_bounding_box = (new_x, new_y, self.canvas_bounding_box[2], self.canvas_bounding_box[3]) + self.image_grid_layout = self._translate_image_grid_layout(self.canvas_padding_left + new_x, self.canvas_padding_top + new_y) + + # Text related public functions + + def get_canvas_title(self, text): + return self._truncate_string(text, 100) + + def get_top_title_location(self) -> Tuple[int, int, int]: + return (self.canvas_bounding_box[0] + self.top_title_padding_left, self.canvas_bounding_box[1] + self.top_title_padding_top, self.canvas_bounding_box[2] - self.top_title_padding_left * 2) + + def get_top_title(self, text): + return self._truncate_string(text, 145) + + def get_bottom_infobar_location(self) -> Tuple[int, int, int]: + return (self.canvas_bounding_box[0] + self.top_title_padding_left, self.canvas_bounding_box[1] + self.canvas_bounding_box[3] - self.bottom_infobar_top_from_bottom, self.canvas_bounding_box[2] - self.top_title_padding_left * 2) + + def get_infotext_location(self) -> Tuple[int, int, int]: + return (self.canvas_bounding_box[0] + self.top_title_padding_left, self.canvas_bounding_box[1] + self.canvas_bounding_box[3] - self.infotext_top_from_bottom, self.canvas_bounding_box[2] - self.top_title_padding_left * 2) + + def get_infotext_label_location(self) -> Tuple[int, int, int]: + return (self.canvas_bounding_box[0] + self.top_title_padding_left, self.canvas_bounding_box[1] + self.canvas_bounding_box[3] - self.infotext_top_from_bottom - 74, self.canvas_bounding_box[2] - self.top_title_padding_left * 2) + + def get_extended_generation_data_label_location(self) -> Tuple[int, int, int]: + return (self.canvas_bounding_box[0] + self.top_title_padding_left, self.canvas_bounding_box[1] + self.canvas_bounding_box[3] - self.bottom_infobar_top_from_bottom - 74, self.canvas_bounding_box[2] - self.top_title_padding_left * 2) + + + # Private functions + + def _calculate_image_grid_layout(self, num_images: int, num_columns: int, image_size: Tuple[int, int], margin: int) -> Tuple[List[Tuple[int, int]], Tuple[int, int, int, int]]: + """ + Calculates a grid layout for the given number of images, based on the desired number of columns, + image size, and margin. + + :param num_images: The number of images. + :param num_columns: The desired number of columns in the grid. + :param image_size: A tuple representing the size (width, height) of each image. + :param margin: The margin in pixels between each image. + :return: A list of tuples representing the position (x, y) of each image in the grid layout. + """ + + image_width, image_height = image_size + + # Calculate the number of rows based on the desired number of columns + num_rows = (num_images + num_columns - 1) // num_columns + + # Calculate the canvas width and height based on the number of columns/rows, image size, and margin + canvas_width = num_columns * image_width + (num_columns - 1) * margin + canvas_height = num_rows * image_height + (num_rows - 1) * margin + self.vertical_seed_margin * num_rows + + # Calculate the width and height of each cell in the grid + cell_width = image_width + cell_height = image_height + + # Calculate the x and y position of each image in the grid + layout = [] + for i in range(num_images): + col = i % num_columns + row = i // num_columns + x = col * (cell_width + margin) + y = row * (cell_height + margin + self.vertical_seed_margin) + layout.append((x, y)) + + bounding_box = (0, 0, canvas_width, canvas_height) + + return layout, bounding_box + + def _create_canvas_bounding_box_at_origin(self, canvas_padding: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]: + """ + Adds padding to each side of the bounding box. + + :param bounding_box: A tuple representing the bounding box coordinates (left, top, width, height). + :param padding_top: The padding value for the top side. + :param padding_bottom: The padding value for the bottom side. + :param padding_left: The padding value for the left side. + :param padding_right: The padding value for the right side. + :return: A tuple representing the new bounding box coordinates with padding applied (left, top, width, height). + """ + + x, y, width, height = self.image_grid_bounding_box + padding_top, padding_bottom, padding_left, padding_right = canvas_padding + + x = 0 + y = 0 + width += padding_left + padding_right + height += padding_top + padding_bottom + + return 0, 0, width, height + + def _translate_image_grid_layout(self, x: int, y: int) -> List[Tuple[int, int]]: + """ + Translates the existing layout to new coordinates based on the target (x, y). + + :param x: The target x-coordinate for translation. + :param y: The target y-coordinate for translation. + :param layout: A list of tuples representing the position (x, y) of each image in the layout. + :return: A list of tuples representing the new layout with translated coordinates. + """ + + # Calculate the translation delta + delta_x = x - self.image_grid_layout[0][0] + delta_y = y - self.image_grid_layout[0][1] + + # Translate the layout coordinates + new_layout = [(lx + delta_x, ly + delta_y) for lx, ly in self.image_grid_layout] + + return new_layout + + def _get_seed_grid_layout(self) -> List[Tuple[int, int]]: + + # TODO: this is silly - the magic 13 + # Translation delta is the height of the image + delta_x = 13 + delta_y = self.image_height + 13 + + # Translate the layout coordinates + seed_grid_layout = [(lx + delta_x, ly + delta_y) for lx, ly in self.image_grid_layout] + + return seed_grid_layout + + def _truncate_string(self, text, max_length): + if len(text) > max_length: + truncated_text = text[:max_length-3] + "..." + else: + truncated_text = text + return truncated_text \ No newline at end of file diff --git a/bs/bluescape_upload.py b/bs/bluescape_upload.py new file mode 100644 index 0000000..28802d9 --- /dev/null +++ b/bs/bluescape_upload.py @@ -0,0 +1,274 @@ +# +# MIT License +# +# Copyright (c) 2023 Thought Stream, LLC dba Bluescape. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +import os +import io +from .expired_token_exception import ExpiredTokenException +from .extension import BluescapeUploadManager +from .config import Config +import modules.scripts as scripts +from modules.processing import Processed, StableDiffusionProcessingImg2Img, StableDiffusionProcessingTxt2Img +import gradio as gr +from .traits import get_canvas_traits, get_image_traits +from .bluescape_layout import BluescapeLayout +import uuid + +class Script(scripts.Script): + + manager = BluescapeUploadManager() + manager.initialize() + + def show(self, is_img2img): + return scripts.AlwaysVisible + + def title(self): + self.manager.state.read_versions(self.filename) + return "Bluescape upload extension" + + def ui(self, is_img2img): + + with gr.Row(variant="panel"): + do_upload = gr.Checkbox(info="Bluescape", label="Upload results to Bluescape") + with gr.Row(variant="panel"): + # Not pretty + if is_img2img: + status = gr.HTML(value=""" +