Initial release 1.0.0 for sd-webui-bluescape

pull/1/head
Antti Piira 2023-06-09 17:08:52 +03:00
parent 9b0505d7da
commit feecb9f69a
29 changed files with 2452 additions and 1 deletions

160
.gitignore vendored Normal file
View File

@ -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/

21
LICENSE Normal file
View File

@ -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.

110
README.md
View File

@ -1 +1,109 @@
# sd-webui-bluescape
# Bluescape Extension for Stable Diffusion WebUI
Upload generated images to a Bluescape workspace for review and collaborate with others.
![Uploading images to Bluescape Worksapce](resources/02-uploading-to-bluescape.png "Uploading images to Bluescape Worksapce")
![Generated images of viking king in a Bluescape Workspace](resources/01-bluescape-workspace.png "Generated images in Bluescape Workspace")
## 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 havent 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 youd 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._
![Bluescape Extension](resources/09-extension.png "Bluescape extension")
## Configuration options
These are the defaults for the extension configuration options:
![Default configuration options for the Bluescape extension](resources/03-configuration-options.png "Configuration options")
### Include extended generation data in workspace
By default the extension adds a text field to the workspace with the basic generation data:
![Generated image with default generation data in Bluescape Workspace](resources/04-generation-data.png "Generation data")
Enabling extended generation data adds another text field with additional generation information:
![Generated image with default and extended generation data in Bluescape Workspace](resources/05-extended-generation-data.png "Extended generation data")
_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:
![Source image included in Bluescape Workspace](resources/06-source-image.png "Source image")
### Include image mask in workspace (img2img)
Whether or not to include the image mask in the workspace when using img2img with masks.
![Image mask included in Bluescape Workspace](resources/10-image-mask.png "Image mask")
### 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:
![Resize to Original context menu item on an image element in Bluescape Workspace](resources/07-resize-to-original-1.png "Resize to original context menu")
![Image element resized to original size in Bluescape Workspace](resources/08-resize-to-original-2.png "Original sized image")
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/).

26
bs/__init__.py Normal file
View File

@ -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

139
bs/analytics.py Normal file
View File

@ -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'
}

619
bs/bluescape_api.py Normal file
View File

@ -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'
}

206
bs/bluescape_layout.py Normal file
View File

@ -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

274
bs/bluescape_upload.py Normal file
View File

@ -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="""
<div style="margin-bottom: var(--spacing-lg); color: var(--block-info-text-color); font-weight: var(--block-info-text-weight); font-size: var(--block-info-text-size); line-height: var(--line-sm);">Status<div>
<div style="margin-top: var(--spacing-lg); color: var(--body-text-color); font-weight: var(--checkbox-label-text-weight); font-size: var(--checkbox-label-text-size);" id="bluescape-status-img2img">Idle</div>
""")
else:
status = gr.HTML(value="""
<div style="margin-bottom: var(--spacing-lg); color: var(--block-info-text-color); font-weight: var(--block-info-text-weight); font-size: var(--block-info-text-size); line-height: var(--line-sm);">Status<div>
<div style="margin-top: var(--spacing-lg); color: var(--body-text-color); font-weight: var(--checkbox-label-text-weight); font-size: var(--checkbox-label-text-size);" id="bluescape-status-txt2img">Idle<div>
""")
return [do_upload, status]
def process(self, p, *args):
# Uploads the UI state
self.manager.set_status("Waiting...", self.is_txt2img)
# Only for AlwaysVisible scripts
def postprocess(self, p, processed: Processed, do_upload, _):
if do_upload == True:
# Lets generate a consistent id for this upload session
upload_id = str(uuid.uuid4())
print(f"Uploading images to Bluescape - (upload_id: {upload_id})")
# Uploads the UI state
self.manager.set_status("Preparing...", self.is_txt2img)
# Check for some common settings
enable_verbose = self.manager.state.enable_verbose
enable_metadata = self.manager.state.enable_metadata
img2img_include_init_images = self.manager.state.img2img_include_init_images
img2img_include_mask_image = self.manager.state.img2img_include_mask_image
scale_to_standard_size = self.manager.state.scale_to_standard_size
# Detect what we are using to generate
generation_type = "unknown"
if self.is_txt2img and not self.is_img2img:
generation_type = "txt2img"
p_img2img = None
elif self.is_img2img and not self.is_txt2img:
generation_type = "img2img"
p_img2img: StableDiffusionProcessingImg2Img = p
else:
print(f"Unkown image generation type - txt2img: {self.is_txt2img}, img2img: {self.is_img2img} - (upload_id: {upload_id})")
# Detect number of images we are dealing with
index_of_first_image = processed.index_of_first_image
num_init_images = 0
include_mask = False
if img2img_include_init_images and generation_type == "img2img" and p_img2img:
num_init_images = len(p_img2img.init_images)
if img2img_include_mask_image and p_img2img.image_mask is not None:
num_init_images = num_init_images + 1
include_mask = True
num_generated_images = len(processed.images) - index_of_first_image
num_images = num_init_images + num_generated_images
image_size = (1000, 1000) if scale_to_standard_size else (processed.width, processed.height)
# Lets calculate the layout
layout = BluescapeLayout(num_images, image_size, enable_verbose)
# How much space we need
canvas_bounding_box = layout.get_canvas_bounding_box()
try:
# Find available space for us
available_canvas_bounding_box = self.manager.find_space(canvas_bounding_box)
# Move layout to target that
layout.translate(available_canvas_bounding_box)
# Create canvas
canvas_title = layout.get_canvas_title("Automatic1111 | " + processed.prompt)
canvas_traits = get_canvas_traits(enable_metadata, processed, generation_type, upload_id, num_images)
canvas_id = self.manager.create_canvas_at(canvas_title, available_canvas_bounding_box, canvas_traits)
# Create title inside the canvas
top_title_location = layout.get_top_title_location()
top_title = layout.get_top_title(processed.prompt)
self.manager.create_top_title(top_title_location, top_title)
# Create generation data section regardless
infotext_location = layout.get_infotext_location()
self.manager.create_generation_data(infotext_location, processed.infotexts[0])
infotext_label_location = layout.get_infotext_label_location()
self.manager.create_generation_label(infotext_label_location, f"Generation data ({generation_type}):")
# Create extended data section only if verbose_mode enabled
if (enable_verbose):
extended_generation_data = [
( "Image CFG scale", processed.image_cfg_scale ),
( "Subseed strength", processed.subseed_strength ),
( "Seed resize from w", processed.seed_resize_from_w ),
( "Seed resize from h", processed.seed_resize_from_h ),
( "DDIM Discretize", processed.ddim_discretize ),
( "ETA", processed.eta ),
( "Clip skip", processed.clip_skip ),
( "Sigma churn", processed.s_churn ),
( "Sigma noise", processed.s_noise ),
( "Sigma tmin", processed.s_tmin ),
( "Sigma tmax", processed.s_tmax ),
( "Sampler noise scheduler override", processed.sampler_noise_scheduler_override ),
( "Is using inpainting conditioning", processed.is_using_inpainting_conditioning ),
( "Extra generation params", processed.extra_generation_params ),
( "Bluescape upload id", upload_id ),
]
bottom_small_infobar_location = layout.get_bottom_infobar_location()
self.manager.create_extended_data(bottom_small_infobar_location, extended_generation_data)
infotext_label_location = layout.get_extended_generation_data_label_location()
self.manager.create_generation_label(infotext_label_location, f"Extended generation data:")
# Get image coordinates
image_layout = layout.get_image_grid_layout()
# Get seed / label coordinates
label_layout = layout.get_label_grid_layout()
# We'll use the shared 'i' between the first iterator (init images)
# and the second one (generated images). This is used to address image_layout
# and seed_layout, which account for both sets of images.
i = 0
# First check for init images (source images)
if img2img_include_init_images and generation_type == "img2img" and p_img2img:
for index, image in enumerate(p_img2img.init_images):
seed = "source_image"
subseed = "unknown"
infotext = "source_image"
self.manager.set_status(f"Uploading image: {i + 1} / {num_images}", self.is_txt2img)
traits = get_image_traits(enable_metadata, processed, seed, subseed, infotext, generation_type, upload_id)
x, y = image_layout[i]
width, height = image_size
# Filename based on the index
filename = f"source-image_{index}.png"
png_data = io.BytesIO()
image.save(png_data, format="PNG")
self.manager.upload_image_at(png_data.getvalue(), filename, (x, y, width, height), traits)
label_x, label_y = label_layout[i]
label_width = image_size[0]
label_height = 50
self.manager.create_label((label_x, label_y, label_width, label_height), "Source image")
i += 1
print(f"Init image has been uploaded to Bluescape - (upload_id: {upload_id})")
if include_mask:
self.manager.set_status(f"Uploading image: {i + 1} / {num_images}", self.is_txt2img)
x, y = image_layout[i]
width, height = image_size
# Filename based on the index
filename = f"image_mask.png"
png_data = io.BytesIO()
p_img2img.image_mask.save(png_data, format="PNG")
self.manager.upload_image_at(png_data.getvalue(), filename, (x, y, width, height), traits)
label_x, label_y = label_layout[i]
label_width = image_size[0]
label_height = 50
self.manager.create_label((label_x, label_y, label_width, label_height), "Image mask")
i += 1
print(f"Mask image has been uploaded to Bluescape - (upload_id: {upload_id})")
# i continues where it was left of above
# Now we'll deal with the generated images
for index, image in enumerate(processed.images):
if index >= index_of_first_image:
adjusted_index = index - index_of_first_image
self.manager.set_status(f"Uploading image: {i + 1} / {num_images}", self.is_txt2img)
seed = processed.all_seeds[adjusted_index]
subseed = processed.all_subseeds[adjusted_index]
infotext = processed.infotexts[adjusted_index]
traits = get_image_traits(enable_metadata, processed, seed, subseed, infotext, generation_type, upload_id)
x, y = image_layout[i]
width, height = image_size
# Filename based on seed and subseed
filename = f"{seed}-{subseed}.png"
png_data = io.BytesIO()
image.save(png_data, format="PNG")
self.manager.upload_image_at(png_data.getvalue(), filename, (x, y, width, height), traits)
label_x, label_y = label_layout[i]
label_width = image_size[0]
label_height = 50
self.manager.create_seed_label((label_x, label_y, label_width, label_height), seed, subseed)
i += 1
print(f"Image {index} has been uploaded to Bluescape - (upload_id: {upload_id})")
else:
print(f"Ignoring generated image with index {index}, as it is smaller than index of first generated image: {index_of_first_image} - (upload_id: {upload_id})")
# Provide a link to the canvas back to the UI
state = self.manager.state
link_to_canvas = f"{Config.client_base_domain}/applink/{state.selected_workspace_id}?objectId={canvas_id}"
self.manager.set_status(f"Upload complete - <a href='{link_to_canvas}' target='_blank'>Click here to open workspace</a>", self.is_txt2img)
# Analytics
self.manager.analytics.send_uploaded_generated_images_event(state.user_token, state.selected_workspace_id, num_images, state.user_id)
print(f"Upload complete - (upload_id: {upload_id})")
except ExpiredTokenException:
self.manager.state.show_token_expired_status_message = True
return True

33
bs/config.py Normal file
View File

@ -0,0 +1,33 @@
#
# 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
class Config:
api_base_domain = os.getenv('BS_API_BASE_DOMAIN', 'https://api.apps.us.bluescape.com')
client_base_domain = os.getenv('BS_CLIENT_BASE_DOMAIN', 'https://client.apps.us.bluescape.com')
analytics_base_domain = os.getenv('BS_ANALYTICS_BASE_DOMAIN', 'https://analytics-api.apps.us.bluescape.com')
isam_base_domain = os.getenv('BS_ISAM_BASE_DOMAIN', 'https://isam.apps.us.bluescape.com')
client_id = os.getenv('BS_CLIENT_ID', 'cbc5407f-1860-4a47-a61f-ec135715aea0')
auth_redirect_url = os.getenv('BS_AUTH_REDIRECT', 'http://localhost:7860/bluescape/oauth_callback')

View File

@ -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.
#
class ExpiredTokenException(Exception):
"Raised when Bluescape token has expired"
pass

327
bs/extension.py Normal file
View File

@ -0,0 +1,327 @@
#
# 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 __future__ import annotations
import json
from typing import Tuple
from .expired_token_exception import ExpiredTokenException
from .templates import bluescape_auth_function, bluescape_open_workspace_function, login_endpoint_page, refresh_ui_page, registration_endpoint_page, expired_token_message_block
from .config import Config
from .analytics import Analytics
from .bluescape_api import bs_create_extended_data, bs_create_canvas_at, bs_create_generation_label, bs_create_generation_data, bs_create_label, bs_create_seed, bs_create_top_title, bs_find_space, bs_upload_image_at, bs_get_user_id
from .state_manager import StateManager
from .misc import extract_workspace_id
import gradio as gr
import modules.scripts as scripts
import uuid
import pkce
from modules import script_callbacks
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
import requests
class BluescapeUploadManager:
state = StateManager()
analytics = Analytics(state)
def initialize(self):
self.state.load()
script_callbacks.on_ui_tabs(self.on_ui_tabs)
script_callbacks.on_app_started(self.on_app_start)
def bluescape_login_endpoint(self):
self.code_verifier, challenge = pkce.generate_pkce_pair()
return login_endpoint_page(challenge)
def bluescape_oauth_callback_endpoint(self, code):
print("Received callback on /bluescape/oauth_callback")
url = f"{Config.isam_base_domain}/api/v3/oauth2/token"
response = requests.post(url, headers = {
'Content-Type': 'application/x-www-form-urlencoded'
},
data = {
'code': code,
'grant_type': 'authorization_code',
'client_id': Config.client_id,
'redirect_uri': Config.auth_redirect_url,
'code_verifier': self.code_verifier
})
if response.status_code == 200:
response_info = json.loads(response.text)
token = response_info['access_token']
user_id = bs_get_user_id(token)
# refresh_workspaces saves state afterwards
self.state.user_token = token
self.state.user_id = user_id
self.state.save()
self.state.show_token_expired_status_message = False
self.analytics.send_user_logged_in_event(token, user_id)
self.state.refresh_workspaces(token)
return refresh_ui_page()
else:
print("OAuth error:")
print(response.text)
def bluescape_logout_endpoint(self):
print("User data is flushed")
self.state.flush_user_data()
return refresh_ui_page()
def bluescape_status_endpoint(self):
if self.state.show_token_expired_status_message:
return expired_token_message_block + "\n" + expired_token_message_block
else:
return self.state.txt2img_status + "\n" + self.state.img2img_status
def bluescape_register_endpoint(self):
registration_attempt_id = str(uuid.uuid4())
self.analytics.send_user_attempting_registration_event(registration_attempt_id)
registration_url = f"{Config.client_base_domain}/signup?utm_campaign=automatic_offer&utm_source=campaign_signup&utm_term={registration_attempt_id}"
return registration_endpoint_page(registration_url)
def set_status(self, status, is_txt2img):
if is_txt2img:
self.state.txt2img_status = status
else:
self.state.img2img_status = status
def on_app_start(self, _, app: FastAPI):
app.add_api_route("/bluescape/login", self.bluescape_login_endpoint, methods=["GET"],
response_class=HTMLResponse)
app.add_api_route("/bluescape/oauth_callback", self.bluescape_oauth_callback_endpoint, methods=["GET"],
response_class=HTMLResponse)
app.add_api_route("/bluescape/registration", self.bluescape_register_endpoint, methods=["GET"], response_class=HTMLResponse)
app.add_api_route("/bluescape/logout", self.bluescape_logout_endpoint, methods=["GET"], response_class=HTMLResponse)
app.add_api_route("/bluescape/status", self.bluescape_status_endpoint, methods=["GET"], response_class=HTMLResponse)
print("Bluescape endpoints have been mounted")
def upload_image_at(self, buffer, filename, bounding_box: Tuple[int, int, int, int], traits):
bs_upload_image_at(self.state.user_token, self.state.selected_workspace_id, buffer, filename, bounding_box, traits)
def create_canvas_at(self, title, bounding_box: Tuple[int, int, int, int], traits):
return bs_create_canvas_at(self.state.user_token, self.state.selected_workspace_id, title, bounding_box, traits)
def find_space(self, bounding_box: Tuple[int, int, int, int]) -> Tuple[int, int, int, int]:
return bs_find_space(self.state.user_token, self.state.selected_workspace_id, bounding_box)
def create_top_title(self, location: Tuple[int, int, int], title):
return bs_create_top_title(self.state.user_token, self.state.selected_workspace_id, location, title)
def create_extended_data(self, location: Tuple[int, int, int], extended_generation_data):
return bs_create_extended_data(self.state.user_token, self.state.selected_workspace_id, location, extended_generation_data)
def create_generation_data(self, location: Tuple[int, int, int], infotext):
return bs_create_generation_data(self.state.user_token, self.state.selected_workspace_id, location, infotext)
def create_seed_label(self, location: Tuple[int, int, int], seed, subseed):
return bs_create_seed(self.state.user_token, self.state.selected_workspace_id, location, seed, subseed)
def create_label(self, location: Tuple[int, int, int], text):
return bs_create_label(self.state.user_token, self.state.selected_workspace_id, location, text)
def create_generation_label(self, location: Tuple[int, int, int], text):
return bs_create_generation_label(self.state.user_token, self.state.selected_workspace_id, location, text)
def get_enable_verbose(self):
return self.state.enable_verbose
def get_img2img_include_init_images(self):
return self.state.img2img_include_init_images
def get_img2img_include_mask_image(self):
return self.state.img2img_include_mask_image
def get_scale_to_standard_size(self):
return self.state.scale_to_standard_size
def get_enable_metadata(self):
return self.state.enable_metadata
def get_enable_analytics(self):
return self.state.enable_analytics
def is_empty_user_token(self):
return self.state.user_token == ""
def get_user_token(self):
return self.state.user_token
def get_workspace_dd(self):
return self.state.workspace_dd
def get_selected_workspace_item(self):
return self.state.get_selected_workspace_item()
def on_ui_tabs(self):
with gr.Blocks() as bluescape_tab:
token_source = gr.Textbox(self.get_user_token, visible=False)
# Outputs: workspaces_dropdown, workspace_to_open_textbox, register_button, refresh_workspaces_button, open_workspace_button, login_button, logout_button
def refresh_workspaces_and_ui(input):
try:
self.state.refresh_workspaces(input)
ws_value = self.state.workspace_ids.get(self.state.selected_workspace_id, self.state.workspace_dd[0])
return [
gr.Dropdown.update(choices=self.state.workspace_dd, visible=True, value=ws_value),
gr.Textbox.update(value=self.state.selected_workspace_id),
gr.Button.update(visible=False),
gr.Button.update(visible=True),
gr.Button.update(visible=True),
gr.Button.update(visible=False),
gr.Button.update(visible=True)
]
except ExpiredTokenException:
print("Bluescape token has expired")
self.state.show_token_expired_status_message = True
self.state.user_token = ""
self.state.save()
return [
gr.Dropdown.update(choices=[], visible=False, value=''),
gr.Textbox.update(value=self.state.selected_workspace_id),
gr.Button.update(visible=True),
gr.Button.update(visible=False),
gr.Button.update(visible=False),
gr.Button.update(visible=True),
gr.Button.update(visible=False),
gr.Checkbox.update(visible=True)
]
with gr.Row():
with gr.Column():
with gr.Row(variant="panel"):
gr.Markdown(
"""
# Instructions
Get started using the Bluescape extension:
1. **Register**: If you haven't done so yet, register for a free account
2. **Login**: Log into your account
3. **Select Workspace**: Choose the workspace where you'd like to upload generated images
4. **Enable Upload Option**: Navigate to either the 'txt2img' or 'img2img' tab and select the "Upload results to Bluescape" option
5. **Review**: Open your workspace to review, curate and collaborate on the generated images
_Tip: Generation data added to the Bluescape workspace can be copied back to Automatic1111 and re-applied through the prompt._
"""
)
with gr.Row():
with gr.Column():
login_button = gr.Button(value="Login", elem_id="bluescape-login-button", visible=self.is_empty_user_token())
logout_button = gr.Button(value="Logout", elem_id="bluescape-logout-button", visible=(not self.is_empty_user_token()))
with gr.Column():
register_button = gr.Button(value="Register for a free account", visible=self.is_empty_user_token())
with gr.Row():
workspaces_dropdown = gr.Dropdown(choices=self.get_workspace_dd(), label="Select target workspace:", value=self.get_selected_workspace_item, elem_id="bluescape-workspaces-dropdown", visible=(not self.is_empty_user_token()))
with gr.Row():
with gr.Column():
workspace_to_open_textbox = gr.Textbox(label="Workspace to open", value=self.get_selected_workspace_item, elem_id="bluescape-open-workspace-id", visible=False)
open_workspace_button = gr.Button("Open target workspace", visible=(not self.is_empty_user_token()))
with gr.Column():
refresh_workspaces_button = gr.Button("Refresh workspaces", visible=(not self.is_empty_user_token()))
with gr.Row():
gr.Markdown(
"""
# Configuration
""")
enable_verbose_checkbox = gr.Checkbox(label="Include extended generation data in workspace", value=self.get_enable_verbose, interactive=True)
img2img_include_init_images_checkbox = gr.Checkbox(label="Include source image in workspace (img2img)", value=self.get_img2img_include_init_images, interactive=True)
img2img_include_mask_image_checkbox = gr.Checkbox(label="Include image mask in workspace (img2img)", value=self.get_img2img_include_mask_image, interactive=True)
scale_to_standard_size_checkbox = gr.Checkbox(label="Scale images to standard size (1000x1000) in workspace", value=self.get_scale_to_standard_size, interactive=True)
enable_metadata_checkbox = gr.Checkbox(label="Store metadata in image object within workspace", value=self.get_enable_metadata, interactive=True)
enable_analytics_checkbox = gr.Checkbox(label="Send extension usage analytics", value=self.get_enable_analytics, interactive=True)
with gr.Row(variant="panel"):
gr.Markdown(
"""
# Feedback
Feel free to send us any feedback you may have. Whether it's praise or constructive criticism.
To share your thoughts, click [here](https://community.bluescape.com/)
For documentation on configuration options visit [Github](https://github.com/Bluescape/sd-webui-bluescape)
"""
)
token_source.change(refresh_workspaces_and_ui, inputs=[token_source], outputs=[workspaces_dropdown, workspace_to_open_textbox, register_button, refresh_workspaces_button, open_workspace_button, login_button, logout_button])
with gr.Column():
# Used to force a second column for better page layout
gr.Textbox(label="dummy", interactive=False, visible=False, value="dummy")
def selected_workspace_change(input):
if input is not None and input != "":
self.state.selected_workspace_id = extract_workspace_id(input)
self.state.save()
return gr.Textbox.update(value=self.state.selected_workspace_id)
def enable_analytics_change(input):
self.state.enable_analytics = input
self.state.save()
def enable_verbose_change(input):
self.state.enable_verbose = input
self.state.save()
def enable_metadata_change(input):
self.state.enable_metadata = input
self.state.save()
def img2img_include_init_images_change(input):
self.state.img2img_include_init_images = input
self.state.save()
def img2img_include_mask_image_change(input):
self.state.img2img_include_mask_image = input
self.state.save()
def scale_to_standard_size_change(input):
self.state.scale_to_standard_size = input
self.state.save()
# Event handlers assignment
login_button.click(None, _js=bluescape_auth_function)
logout_button.click(None, _js="bluescape_logout")
open_workspace_button.click(None, _js=bluescape_open_workspace_function)
register_button.click(None, _js="bluescape_registration")
refresh_workspaces_button.click(refresh_workspaces_and_ui, inputs=[token_source], outputs=[workspaces_dropdown, workspace_to_open_textbox, register_button, refresh_workspaces_button, open_workspace_button, login_button, logout_button])
workspaces_dropdown.change(selected_workspace_change, inputs=[workspaces_dropdown], outputs=[workspace_to_open_textbox])
enable_verbose_checkbox.change(enable_verbose_change, inputs=[enable_verbose_checkbox])
img2img_include_init_images_checkbox.change(img2img_include_init_images_change, inputs=[img2img_include_init_images_checkbox])
img2img_include_mask_image_checkbox.change(img2img_include_mask_image_change, inputs=[img2img_include_mask_image_checkbox])
scale_to_standard_size_checkbox.change(scale_to_standard_size_change, inputs=[scale_to_standard_size_checkbox])
enable_metadata_checkbox.change(enable_metadata_change, inputs=[enable_metadata_checkbox])
enable_analytics_checkbox.change(enable_analytics_change, inputs=[enable_analytics_checkbox])
return ((bluescape_tab, "Bluescape", "bluescape-tab"),)

31
bs/misc.py Normal file
View File

@ -0,0 +1,31 @@
#
# 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.
#
def extract_workspace_id(input):
parts = input.split()
workspace_part = parts[-1]
workspace_id = workspace_part[1:-1]
if len(workspace_id) != 20:
raise
return workspace_id

154
bs/state_manager.py Normal file
View File

@ -0,0 +1,154 @@
#
# 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 json
import os
from .bluescape_api import bs_get_all_workspaces
from appdirs import AppDirs
import subprocess
from pathlib import Path
class StateManager:
user_token = ""
user_id = ""
# Array for dropdown values - labels
workspace_dd = {}
# Dictionary to lookup name by workspace id
workspace_ids = {}
selected_workspace_id = ""
enable_verbose = False
img2img_include_init_images = True
scale_to_standard_size = True
enable_metadata = True
enable_analytics = True
show_token_expired_status_message = False
img2img_include_mask_image = False
txt2img_status = ""
img2img_status = ""
a1111_version = "unknown"
extension_version = "unknown"
def __init__(self):
dirs = AppDirs("a1111-sd-extension", "Bluescape")
os.makedirs(dirs.user_data_dir, exist_ok=True)
self.state_file = os.path.join(dirs.user_data_dir, "bs_state.json")
print("State file for your system: " + self.state_file)
def read_versions(self, extension_file_path):
if self.a1111_version != "unknown":
return
try:
self.a1111_version = subprocess.check_output(["git", "rev-parse", "HEAD"]).strip().decode()
except Exception:
pass
try:
extension_root_dir = Path(extension_file_path).parent.parent
version_file = str(extension_root_dir) + '/version.txt'
with open(version_file) as f:
self.extension_version = f.read()
except Exception:
pass
def flush_user_data(self):
self.user_token = ""
self.user_id = ""
self.workspace_dd = {}
self.workspace_ids = {}
self.selected_workspace_id = ""
self.save()
def refresh_workspaces(self, token):
workspaces = bs_get_all_workspaces(token)
print(f"Total {len(workspaces)} workspaces retrieved")
self.workspace_ids = {}
self.workspace_dd = []
for w in workspaces:
dd_name = f"{w['name']} ({w['id']})"
self.workspace_ids[w['id']] = dd_name
self.workspace_dd.append(dd_name)
# If we don't have an existing selected id, lets select the first one
if self.selected_workspace_id == "" or self.selected_workspace_id not in self.workspace_ids.keys():
self.selected_workspace_id = next(iter(self.workspace_ids))
self.save()
def get_selected_workspace_item(self):
selected_workspace = None
if self.selected_workspace_id is not None and self.selected_workspace_id != "" and self.selected_workspace_id in self.workspace_ids:
selected_workspace = self.workspace_ids[self.selected_workspace_id]
return selected_workspace
def save(self):
with open(self.state_file, "w+") as out_file:
json.dump({
"user_id": self.user_id,
"selected_workspace_id": self.selected_workspace_id,
"enable_verbose": self.enable_verbose,
"img2img_include_init_images": self.img2img_include_init_images,
"img2img_include_mask_image": self.img2img_include_mask_image,
"scale_to_standard_size": self.scale_to_standard_size,
"enable_metadata": self.enable_metadata,
"enable_analytics": self.enable_analytics,
"workspace_dd": self.workspace_dd,
"workspace_ids": self.workspace_ids,
"user_token": self.user_token
}, out_file, indent = 6)
out_file.close()
def load(self):
if not os.path.exists(self.state_file):
return
try:
with open(self.state_file) as f:
data = json.load(f)
try:
self.user_id = data['user_id']
self.selected_workspace_id = data['selected_workspace_id']
self.enable_verbose = data['enable_verbose']
self.img2img_include_init_images = data['img2img_include_init_images']
self.img2img_include_mask_image = data['img2img_include_mask_image']
self.scale_to_standard_size = data['scale_to_standard_size']
self.enable_metadata = data['enable_metadata']
self.enable_analytics = data['enable_analytics']
self.workspace_dd = data['workspace_dd']
self.workspace_ids = data['workspace_ids']
self.user_token = data['user_token']
except KeyError:
print('Unsupported state file version, flushing state...')
f.close()
except json.JSONDecodeError:
print("JSONDecode Error")
return

69
bs/templates.py Normal file
View File

@ -0,0 +1,69 @@
#
# 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 .config import Config
def login_endpoint_page(challenge):
return f"""
<script>
const url = `{Config.isam_base_domain}/api/v3/oauth2/authorize?client_id={Config.client_id}&redirect_uri={Config.auth_redirect_url}&response_type=code&scope=v2legacy%20offline_access&state=1a2b3c4d&code_challenge={challenge}&code_challenge_method=S256`;
window.location = url;
</script>
"""
def registration_endpoint_page(registration_url):
return f"""
<script>
localStorage.setItem("bluescapeRegistrationStarted", true);
window.location = '{registration_url}';
</script>
"""
def refresh_ui_page():
return f"""
<script>
localStorage.setItem("bluescapeRefreshUI", true);
window.location = "/";
</script>
"""
bluescape_auth_function = f"""
function bluescape_auth() {{
window.location = '/bluescape/login';
}}
"""
bluescape_open_workspace_function = f"""
function open_selected_workspace() {{
const selectedWorkspace = document.querySelector("#bluescape-open-workspace-id textarea");
if (selectedWorkspace) {{
workspaceId = selectedWorkspace.value;
const url = '{Config.client_base_domain}' + '/' + workspaceId;
window.open(url, "_blank");
}}
}}
"""
expired_token_message_block = "<span style='color:red;'>Bluescape auth has expired. Please login in the Bluescape tab.</span>"

118
bs/traits.py Normal file
View File

@ -0,0 +1,118 @@
#
# 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 modules.processing import Processed
def get_image_traits(enable_metadata: bool, processed: Processed, seed, subseed, infotext, generation_type, upload_id):
if enable_metadata:
traits = {
"http://bluescape.dev/automatic1111-extension/v1/enabled": True,
"http://bluescape.dev/automatic1111-extension/v1/postprocess": str({
"type": str(generation_type),
"prompt": str(processed.prompt),
"negative_prompt": str(processed.negative_prompt),
"seed": str(seed),
"subseed": str(subseed),
"infotext": str(infotext),
"subseed_strength": str(processed.subseed_strength),
"width": str(processed.width),
"height": str(processed.height),
"sampler_name": str(processed.sampler_name),
"cfg_scale": str(processed.cfg_scale),
"image_cfg_scale": str(processed.image_cfg_scale),
"restore_faces": str(processed.restore_faces),
"face_restoration_model": str(processed.face_restoration_model),
"sd_model_hash": str(processed.sd_model_hash),
"seed_resize_from_w": str(processed.seed_resize_from_w),
"seed_resize_from_h": str(processed.seed_resize_from_h),
"denoising_strength": str(processed.denoising_strength),
"extra_generation_params": str(processed.extra_generation_params),
"clip_skip": str(processed.clip_skip), # missing in infobar
"eta": str(processed.eta),
"ddim_discretize": str(processed.ddim_discretize),
"s_churn": str(processed.s_churn),
"s_tmin": str(processed.s_tmin),
"s_tmax": str(processed.s_tmax),
"s_noise": str(processed.s_noise),
"sampler_noise_scheduler_override": str(processed.sampler_noise_scheduler_override),
"is_using_inpainting_conditioning": str(processed.is_using_inpainting_conditioning),
"upload_id": str(upload_id),
}),
"http://bluescape.dev/automatic1111-extension/v1/uploadId": str(upload_id),
}
else:
traits = {
"http://bluescape.dev/automatic1111-extension/v1/enabled": False,
}
return traits
def get_canvas_traits(enable_metadata: bool, processed: Processed, generation_type, upload_id, num_images):
if enable_metadata:
traits = {
"http://bluescape.dev/automatic1111-extension/v1/enabled": True,
"http://bluescape.dev/automatic1111-extension/v1/processed": str({
"type": str(generation_type),
"prompt": str(processed.prompt),
"negative_prompt": str(processed.negative_prompt),
# No seed, subseed
"subseed_strength": str(processed.subseed_strength),
"width": str(processed.width),
"height": str(processed.height),
"sampler_name": str(processed.sampler_name),
"cfg_scale": str(processed.cfg_scale),
"image_cfg_scale": str(processed.image_cfg_scale),
"restore_faces": str(processed.restore_faces),
"face_restoration_model": str(processed.face_restoration_model),
"sd_model_hash": str(processed.sd_model_hash),
"seed_resize_from_w": str(processed.seed_resize_from_w),
"seed_resize_from_h": str(processed.seed_resize_from_h),
"denoising_strength": str(processed.denoising_strength),
"extra_generation_params": str(processed.extra_generation_params),
"clip_skip": str(processed.clip_skip), # missing in infobar
"eta": str(processed.eta),
"ddim_discretize": str(processed.ddim_discretize),
"s_churn": str(processed.s_churn),
"s_tmin": str(processed.s_tmin),
"s_tmax": str(processed.s_tmax),
"s_noise": str(processed.s_noise),
"sampler_noise_scheduler_override": str(processed.sampler_noise_scheduler_override),
"is_using_inpainting_conditioning": str(processed.is_using_inpainting_conditioning),
"num_images": str(num_images),
"all_prompts": str(processed.all_prompts),
"all_negative_prompts": str(processed.all_negative_prompts),
"all_seeds": str(processed.all_seeds),
"all_subseeds": str(processed.all_subseeds),
"infotexts": str(processed.infotexts),
"upload_id": str(upload_id),
}),
"http://bluescape.dev/automatic1111-extension/v1/uploadId": str(upload_id),
}
else:
traits = {
"http://bluescape.dev/automatic1111-extension/v1/enabled": False,
}
return traits

30
install.py Normal file
View File

@ -0,0 +1,30 @@
#
# 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 launch
if not launch.is_installed("appdirs"):
launch.run_pip("install appdirs==1.4.4", "requirements for Bluescape extension")
if not launch.is_installed("pkce"):
launch.run_pip("install pkce==1.0.3", "requirements for Bluescape extension")

View File

@ -0,0 +1,82 @@
/*
* 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.
*/
function bluescape_registration() {
window.open("/bluescape/registration", "_blank");
}
function bluescape_logout() {
window.location = "/bluescape/logout";
}
function bluescape_update_status(input) {
const statuses = input.split("\n")
const txt2imgStatusLabel = document.getElementById("bluescape-status-txt2img");
const img2imgStatusLabel = document.getElementById("bluescape-status-img2img");
// This is a bit error prone and should be cleaned up
if (statuses[0] && txt2imgStatusLabel) {
txt2imgStatusLabel.innerHTML = statuses[0]
}
if (statuses[1] && img2imgStatusLabel) {
img2imgStatusLabel.innerHTML = statuses[1]
}
}
document.addEventListener("DOMContentLoaded", function () {
const bsAuthSuccess = localStorage.getItem("bluescapeRefreshUI");
if (bsAuthSuccess) {
localStorage.removeItem("bluescapeRefreshUI");
RetryCallback(ClickOnBluescapeTab, 500, 6);
}
// Status polling
statusInterval = setInterval(async () => {
try {
const response = await fetch("/bluescape/status");
bluescape_update_status(await response.text());
}
catch (error) {
// Do nothing
}
}, 2000);
});
function RetryCallback(callback, delay, tries) {
if (tries && callback() !== true) {
setTimeout(RetryCallback.bind(this, callback, delay, tries - 1), delay);
}
}
function ClickOnBluescapeTab() {
const links = Array.from(document.querySelectorAll('#tabs .tab-nav button'));
let clicked = false;
links.forEach((link) => {
if (link.textContent === "Bluescape ") {
link.click();
clicked = true;
}
});
return clicked;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

BIN
resources/09-extension.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 KiB

BIN
resources/10-image-mask.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@ -0,0 +1,27 @@
#
# 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 bs.bluescape_upload import Script
__all__ = ["Script"]

1
version.txt Normal file
View File

@ -0,0 +1 @@
0.8.1