diff --git a/CHANGES.md b/CHANGES.md index 2e45c279..b53ac141 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -22,6 +22,15 @@ Our focus continues to remain on an easy installation experience, and an easy user-interface. While still remaining pretty powerful, in terms of features and speed. ### Detailed changelog +* 2.5.41 - 13 Jun 2023 - Fix multi-gpu bug with "low" VRAM usage mode while generating images. +* 2.5.41 - 12 Jun 2023 - Fix multi-gpu bug with CodeFormer. +* 2.5.41 - 6 Jun 2023 - Allow changing the strength of CodeFormer, and slightly improved styling of the CodeFormer options. +* 2.5.41 - 5 Jun 2023 - Allow sharing an Easy Diffusion instance via https://try.cloudflare.com/ . You can find this option at the bottom of the Settings tab. Thanks @JeLuf. +* 2.5.41 - 5 Jun 2023 - Show an option to download for tiled images. Shows a button on the generated image. Creates larger images by tiling them with the image generated by Easy Diffusion. Thanks @JeLuf. +* 2.5.41 - 5 Jun 2023 - (beta-only) Allow LoRA strengths between -2 and 2. Thanks @ogmaresca. +* 2.5.40 - 5 Jun 2023 - Reduce the VRAM usage of Latent Upscaling when using "balanced" VRAM usage mode. +* 2.5.40 - 5 Jun 2023 - Fix the "realesrgan" key error when using CodeFormer with more than 1 image in a batch. +* 2.5.40 - 3 Jun 2023 - Added CodeFormer as another option for fixing faces and eyes. CodeFormer tends to perform better than GFPGAN for many images. Thanks @patriceac for the implementation, and for contacting the CodeFormer team (who were supportive of it being integrated into Easy Diffusion). * 2.5.39 - 25 May 2023 - (beta-only) Seamless Tiling - make seamlessly tiled images, e.g. rock and grass textures. Thanks @JeLuf. * 2.5.38 - 24 May 2023 - Better reporting of errors, and show an explanation if the user cannot disable the "Use CPU" setting. * 2.5.38 - 23 May 2023 - Add Latent Upscaler as another option for upscaling images. Thanks @JeLuf for the implementation of the Latent Upscaler model. diff --git a/scripts/check_models.py b/scripts/check_models.py deleted file mode 100644 index 4b8d68c6..00000000 --- a/scripts/check_models.py +++ /dev/null @@ -1,101 +0,0 @@ -# this script runs inside the legacy "stable-diffusion" folder - -from sdkit.models import download_model, get_model_info_from_db -from sdkit.utils import hash_file_quick - -import os -import shutil -from glob import glob -import traceback - -models_base_dir = os.path.abspath(os.path.join("..", "models")) - -models_to_check = { - "stable-diffusion": [ - {"file_name": "sd-v1-4.ckpt", "model_id": "1.4"}, - ], - "gfpgan": [ - {"file_name": "GFPGANv1.4.pth", "model_id": "1.4"}, - ], - "realesrgan": [ - {"file_name": "RealESRGAN_x4plus.pth", "model_id": "x4plus"}, - {"file_name": "RealESRGAN_x4plus_anime_6B.pth", "model_id": "x4plus_anime_6"}, - ], - "vae": [ - {"file_name": "vae-ft-mse-840000-ema-pruned.ckpt", "model_id": "vae-ft-mse-840000-ema-pruned"}, - ], -} -MODEL_EXTENSIONS = { # copied from easydiffusion/model_manager.py - "stable-diffusion": [".ckpt", ".safetensors"], - "vae": [".vae.pt", ".ckpt", ".safetensors"], - "hypernetwork": [".pt", ".safetensors"], - "gfpgan": [".pth"], - "realesrgan": [".pth"], - "lora": [".ckpt", ".safetensors"], -} - - -def download_if_necessary(model_type: str, file_name: str, model_id: str): - model_path = os.path.join(models_base_dir, model_type, file_name) - expected_hash = get_model_info_from_db(model_type=model_type, model_id=model_id)["quick_hash"] - - other_models_exist = any_model_exists(model_type) - known_model_exists = os.path.exists(model_path) - known_model_is_corrupt = known_model_exists and hash_file_quick(model_path) != expected_hash - - if known_model_is_corrupt or (not other_models_exist and not known_model_exists): - print("> download", model_type, model_id) - download_model(model_type, model_id, download_base_dir=models_base_dir) - - -def init(): - migrate_legacy_model_location() - - for model_type, models in models_to_check.items(): - for model in models: - try: - download_if_necessary(model_type, model["file_name"], model["model_id"]) - except: - traceback.print_exc() - fail(model_type) - - print(model_type, "model(s) found.") - - -### utilities -def any_model_exists(model_type: str) -> bool: - extensions = MODEL_EXTENSIONS.get(model_type, []) - for ext in extensions: - if any(glob(f"{models_base_dir}/{model_type}/**/*{ext}", recursive=True)): - return True - - return False - - -def migrate_legacy_model_location(): - 'Move the models inside the legacy "stable-diffusion" folder, to their respective folders' - - for model_type, models in models_to_check.items(): - for model in models: - file_name = model["file_name"] - if os.path.exists(file_name): - dest_dir = os.path.join(models_base_dir, model_type) - os.makedirs(dest_dir, exist_ok=True) - shutil.move(file_name, os.path.join(dest_dir, file_name)) - - -def fail(model_name): - print( - f"""Error downloading the {model_name} model. Sorry about that, please try to: -1. Run this installer again. -2. If that doesn't fix it, please try to download the file manually. The address to download from, and the destination to save to are printed above this message. -3. If those steps don't help, please copy *all* the error messages in this window, and ask the community at https://discord.com/invite/u9yhsFmEkB -4. If that doesn't solve the problem, please file an issue at https://github.com/cmdr2/stable-diffusion-ui/issues -Thanks!""" - ) - exit(1) - - -### start - -init() diff --git a/scripts/check_modules.py b/scripts/check_modules.py index 3686ca00..6275de45 100644 --- a/scripts/check_modules.py +++ b/scripts/check_modules.py @@ -18,13 +18,15 @@ os_name = platform.system() modules_to_check = { "torch": ("1.11.0", "1.13.1", "2.0.0"), "torchvision": ("0.12.0", "0.14.1", "0.15.1"), - "sdkit": "1.0.98", + "sdkit": "1.0.106", "stable-diffusion-sdkit": "2.1.4", "rich": "12.6.0", "uvicorn": "0.19.0", "fastapi": "0.85.1", + "pycloudflared": "0.2.0", # "xformers": "0.0.16", } +modules_to_log = ["torch", "torchvision", "sdkit", "stable-diffusion-sdkit"] def version(module_name: str) -> str: @@ -89,7 +91,8 @@ def init(): traceback.print_exc() fail(module_name) - print(f"{module_name}: {version(module_name)}") + if module_name in modules_to_log: + print(f"{module_name}: {version(module_name)}") ### utilities diff --git a/scripts/developer_console.sh b/scripts/developer_console.sh index 73972568..57846eeb 100755 --- a/scripts/developer_console.sh +++ b/scripts/developer_console.sh @@ -39,6 +39,8 @@ if [ "$0" == "bash" ]; then export PYTHONPATH="$(pwd)/stable-diffusion/env/lib/python3.8/site-packages" fi + export PYTHONNOUSERSITE=y + which python python --version diff --git a/scripts/on_env_start.bat b/scripts/on_env_start.bat index 44144cfa..bc92d0e9 100644 --- a/scripts/on_env_start.bat +++ b/scripts/on_env_start.bat @@ -67,7 +67,6 @@ if "%update_branch%"=="" ( @xcopy sd-ui-files\ui ui /s /i /Y /q @copy sd-ui-files\scripts\on_sd_start.bat scripts\ /Y @copy sd-ui-files\scripts\check_modules.py scripts\ /Y -@copy sd-ui-files\scripts\check_models.py scripts\ /Y @copy sd-ui-files\scripts\get_config.py scripts\ /Y @copy "sd-ui-files\scripts\Start Stable Diffusion UI.cmd" . /Y @copy "sd-ui-files\scripts\Developer Console.cmd" . /Y diff --git a/scripts/on_env_start.sh b/scripts/on_env_start.sh index 30465975..366b5dd1 100755 --- a/scripts/on_env_start.sh +++ b/scripts/on_env_start.sh @@ -50,7 +50,6 @@ cp -Rf sd-ui-files/ui . cp sd-ui-files/scripts/on_sd_start.sh scripts/ cp sd-ui-files/scripts/bootstrap.sh scripts/ cp sd-ui-files/scripts/check_modules.py scripts/ -cp sd-ui-files/scripts/check_models.py scripts/ cp sd-ui-files/scripts/get_config.py scripts/ cp sd-ui-files/scripts/start.sh . cp sd-ui-files/scripts/developer_console.sh . diff --git a/scripts/on_sd_start.bat b/scripts/on_sd_start.bat index ba205c9e..f92b9f6f 100644 --- a/scripts/on_sd_start.bat +++ b/scripts/on_sd_start.bat @@ -5,7 +5,6 @@ @copy sd-ui-files\scripts\on_env_start.bat scripts\ /Y @copy sd-ui-files\scripts\check_modules.py scripts\ /Y -@copy sd-ui-files\scripts\check_models.py scripts\ /Y @copy sd-ui-files\scripts\get_config.py scripts\ /Y if exist "%cd%\profile" ( @@ -79,13 +78,6 @@ call WHERE uvicorn > .tmp @echo conda_sd_ui_deps_installed >> ..\scripts\install_status.txt ) -@rem Download the required models -call python ..\scripts\check_models.py -if "%ERRORLEVEL%" NEQ "0" ( - pause - exit /b -) - @>nul findstr /m "sd_install_complete" ..\scripts\install_status.txt @if "%ERRORLEVEL%" NEQ "0" ( @echo sd_weights_downloaded >> ..\scripts\install_status.txt diff --git a/scripts/on_sd_start.sh b/scripts/on_sd_start.sh index 820c36ed..be5161d4 100755 --- a/scripts/on_sd_start.sh +++ b/scripts/on_sd_start.sh @@ -4,7 +4,6 @@ cp sd-ui-files/scripts/functions.sh scripts/ cp sd-ui-files/scripts/on_env_start.sh scripts/ cp sd-ui-files/scripts/bootstrap.sh scripts/ cp sd-ui-files/scripts/check_modules.py scripts/ -cp sd-ui-files/scripts/check_models.py scripts/ cp sd-ui-files/scripts/get_config.py scripts/ source ./scripts/functions.sh @@ -51,12 +50,6 @@ if ! command -v uvicorn &> /dev/null; then fail "UI packages not found!" fi -# Download the required models -if ! python ../scripts/check_models.py; then - read -p "Press any key to continue" - exit 1 -fi - if [ `grep -c sd_install_complete ../scripts/install_status.txt` -gt "0" ]; then echo sd_weights_downloaded >> ../scripts/install_status.txt echo sd_install_complete >> ../scripts/install_status.txt diff --git a/ui/easydiffusion/app.py b/ui/easydiffusion/app.py index 3064e151..38e3392c 100644 --- a/ui/easydiffusion/app.py +++ b/ui/easydiffusion/app.py @@ -90,8 +90,8 @@ def init(): os.makedirs(USER_SERVER_PLUGINS_DIR, exist_ok=True) # https://pytorch.org/docs/stable/storage.html - warnings.filterwarnings('ignore', category=UserWarning, message='TypedStorage is deprecated') - + warnings.filterwarnings("ignore", category=UserWarning, message="TypedStorage is deprecated") + load_server_plugins() update_render_threads() @@ -221,12 +221,41 @@ def open_browser(): webbrowser.open(f"http://localhost:{port}") - Console().print(Panel( - "\n" + - "[white]Easy Diffusion is ready to serve requests.\n\n" + - "A new browser tab should have been opened by now.\n" + - f"If not, please open your web browser and navigate to [bold yellow underline]http://localhost:{port}/\n", - title="Easy Diffusion is ready", style="bold yellow on blue")) + Console().print( + Panel( + "\n" + + "[white]Easy Diffusion is ready to serve requests.\n\n" + + "A new browser tab should have been opened by now.\n" + + f"If not, please open your web browser and navigate to [bold yellow underline]http://localhost:{port}/\n", + title="Easy Diffusion is ready", + style="bold yellow on blue", + ) + ) + + +def fail_and_die(fail_type: str, data: str): + suggestions = [ + "Run this installer again.", + "If those steps don't help, please copy *all* the error messages in this window, and ask the community at https://discord.com/invite/u9yhsFmEkB", + "If that doesn't solve the problem, please file an issue at https://github.com/cmdr2/stable-diffusion-ui/issues", + ] + + if fail_type == "model_download": + fail_label = f"Error downloading the {data} model" + suggestions.insert( + 1, + "If that doesn't fix it, please try to download the file manually. The address to download from, and the destination to save to are printed above this message.", + ) + else: + fail_label = "Error while installing Easy Diffusion" + + msg = [f"{fail_label}. Sorry about that, please try to:"] + for i, suggestion in enumerate(suggestions): + msg.append(f"{i+1}. {suggestion}") + msg.append("Thanks!") + + print("\n".join(msg)) + exit(1) def get_image_modifiers(): diff --git a/ui/easydiffusion/model_manager.py b/ui/easydiffusion/model_manager.py index 0a1f1b5c..de2c10ac 100644 --- a/ui/easydiffusion/model_manager.py +++ b/ui/easydiffusion/model_manager.py @@ -1,10 +1,14 @@ import os +import shutil +from glob import glob +import traceback from easydiffusion import app from easydiffusion.types import TaskData from easydiffusion.utils import log from sdkit import Context -from sdkit.models import load_model, scan_model, unload_model +from sdkit.models import load_model, scan_model, unload_model, download_model, get_model_info_from_db +from sdkit.utils import hash_file_quick KNOWN_MODEL_TYPES = [ "stable-diffusion", @@ -13,6 +17,7 @@ KNOWN_MODEL_TYPES = [ "gfpgan", "realesrgan", "lora", + "codeformer", ] MODEL_EXTENSIONS = { "stable-diffusion": [".ckpt", ".safetensors"], @@ -21,14 +26,22 @@ MODEL_EXTENSIONS = { "gfpgan": [".pth"], "realesrgan": [".pth"], "lora": [".ckpt", ".safetensors"], + "codeformer": [".pth"], } DEFAULT_MODELS = { - "stable-diffusion": [ # needed to support the legacy installations - "custom-model", # only one custom model file was supported initially, creatively named 'custom-model' - "sd-v1-4", # Default fallback. + "stable-diffusion": [ + {"file_name": "sd-v1-4.ckpt", "model_id": "1.4"}, + ], + "gfpgan": [ + {"file_name": "GFPGANv1.4.pth", "model_id": "1.4"}, + ], + "realesrgan": [ + {"file_name": "RealESRGAN_x4plus.pth", "model_id": "x4plus"}, + {"file_name": "RealESRGAN_x4plus_anime_6B.pth", "model_id": "x4plus_anime_6"}, + ], + "vae": [ + {"file_name": "vae-ft-mse-840000-ema-pruned.ckpt", "model_id": "vae-ft-mse-840000-ema-pruned"}, ], - "gfpgan": ["GFPGANv1.3"], - "realesrgan": ["RealESRGAN_x4plus"], } MODELS_TO_LOAD_ON_START = ["stable-diffusion", "vae", "hypernetwork", "lora"] @@ -37,6 +50,8 @@ known_models = {} def init(): make_model_folders() + migrate_legacy_model_location() # if necessary + download_default_models_if_necessary() getModels() # run this once, to cache the picklescan results @@ -45,7 +60,7 @@ def load_default_models(context: Context): # init default model paths for model_type in MODELS_TO_LOAD_ON_START: - context.model_paths[model_type] = resolve_model_to_use(model_type=model_type) + context.model_paths[model_type] = resolve_model_to_use(model_type=model_type, fail_if_not_found=False) try: load_model( context, @@ -57,7 +72,12 @@ def load_default_models(context: Context): del context.model_load_errors[model_type] except Exception as e: log.error(f"[red]Error while loading {model_type} model: {context.model_paths[model_type]}[/red]") - log.exception(e) + if "DefaultCPUAllocator: not enough memory" in str(e): + log.error( + f"[red]Your PC is low on system RAM. Please add some virtual memory (or swap space) by following the instructions at this link: https://www.ibm.com/docs/en/opw/8.2.0?topic=tuning-optional-increasing-paging-file-size-windows-computers[/red]" + ) + else: + log.exception(e) del context.model_paths[model_type] context.model_load_errors[model_type] = str(e) # storing the entire Exception can lead to memory leaks @@ -70,12 +90,12 @@ def unload_all(context: Context): del context.model_load_errors[model_type] -def resolve_model_to_use(model_name: str = None, model_type: str = None): +def resolve_model_to_use(model_name: str = None, model_type: str = None, fail_if_not_found: bool = True): model_extensions = MODEL_EXTENSIONS.get(model_type, []) default_models = DEFAULT_MODELS.get(model_type, []) config = app.getConfig() - model_dirs = [os.path.join(app.MODELS_DIR, model_type), app.SD_DIR] + model_dir = os.path.join(app.MODELS_DIR, model_type) if not model_name: # When None try user configured model. # config = getConfig() if "model" in config and model_type in config["model"]: @@ -83,45 +103,42 @@ def resolve_model_to_use(model_name: str = None, model_type: str = None): if model_name: # Check models directory - models_dir_path = os.path.join(app.MODELS_DIR, model_type, model_name) + model_path = os.path.join(model_dir, model_name) + if os.path.exists(model_path): + return model_path for model_extension in model_extensions: - if os.path.exists(models_dir_path + model_extension): - return models_dir_path + model_extension + if os.path.exists(model_path + model_extension): + return model_path + model_extension if os.path.exists(model_name + model_extension): return os.path.abspath(model_name + model_extension) - # Default locations - if model_name in default_models: - default_model_path = os.path.join(app.SD_DIR, model_name) - for model_extension in model_extensions: - if os.path.exists(default_model_path + model_extension): - return default_model_path + model_extension - # Can't find requested model, check the default paths. - for default_model in default_models: - for model_dir in model_dirs: - default_model_path = os.path.join(model_dir, default_model) - for model_extension in model_extensions: - if os.path.exists(default_model_path + model_extension): - if model_name is not None: - log.warn( - f"Could not find the configured custom model {model_name}{model_extension}. Using the default one: {default_model_path}{model_extension}" - ) - return default_model_path + model_extension + if model_type == "stable-diffusion" and not fail_if_not_found: + for default_model in default_models: + default_model_path = os.path.join(model_dir, default_model["file_name"]) + if os.path.exists(default_model_path): + if model_name is not None: + log.warn( + f"Could not find the configured custom model {model_name}. Using the default one: {default_model_path}" + ) + return default_model_path - return None + if model_name and fail_if_not_found: + raise Exception(f"Could not find the desired model {model_name}! Is it present in the {model_dir} folder?") def reload_models_if_necessary(context: Context, task_data: TaskData): - use_upscale_lower = task_data.use_upscale.lower() if task_data.use_upscale else "" + face_fix_lower = task_data.use_face_correction.lower() if task_data.use_face_correction else "" + upscale_lower = task_data.use_upscale.lower() if task_data.use_upscale else "" model_paths_in_req = { "stable-diffusion": task_data.use_stable_diffusion_model, "vae": task_data.use_vae_model, "hypernetwork": task_data.use_hypernetwork_model, - "gfpgan": task_data.use_face_correction, - "realesrgan": task_data.use_upscale if "realesrgan" in use_upscale_lower else None, - "latent_upscaler": True if task_data.use_upscale == "latent_upscaler" else None, + "codeformer": task_data.use_face_correction if "codeformer" in face_fix_lower else None, + "gfpgan": task_data.use_face_correction if "gfpgan" in face_fix_lower else None, + "realesrgan": task_data.use_upscale if "realesrgan" in upscale_lower else None, + "latent_upscaler": True if "latent_upscaler" in upscale_lower else None, "nsfw_checker": True if task_data.block_nsfw else None, "lora": task_data.use_lora_model, } @@ -131,6 +148,13 @@ def reload_models_if_necessary(context: Context, task_data: TaskData): if context.model_paths.get(model_type) != path } + if task_data.codeformer_upscale_faces: + if "realesrgan" not in models_to_reload and "realesrgan" not in context.models: + default_realesrgan = DEFAULT_MODELS["realesrgan"][0]["file_name"] + models_to_reload["realesrgan"] = resolve_model_to_use(default_realesrgan, "realesrgan") + elif "realesrgan" in models_to_reload and models_to_reload["realesrgan"] is None: + del models_to_reload["realesrgan"] # don't unload realesrgan + if set_vram_optimizations(context) or set_clip_skip(context, task_data): # reload SD models_to_reload["stable-diffusion"] = model_paths_in_req["stable-diffusion"] @@ -157,7 +181,13 @@ def resolve_model_paths(task_data: TaskData): task_data.use_lora_model = resolve_model_to_use(task_data.use_lora_model, model_type="lora") if task_data.use_face_correction: - task_data.use_face_correction = resolve_model_to_use(task_data.use_face_correction, "gfpgan") + if "gfpgan" in task_data.use_face_correction.lower(): + model_type = "gfpgan" + elif "codeformer" in task_data.use_face_correction.lower(): + model_type = "codeformer" + download_if_necessary("codeformer", "codeformer.pth", "codeformer-0.1.0") + + task_data.use_face_correction = resolve_model_to_use(task_data.use_face_correction, model_type) if task_data.use_upscale and "realesrgan" in task_data.use_upscale.lower(): task_data.use_upscale = resolve_model_to_use(task_data.use_upscale, "realesrgan") @@ -167,7 +197,31 @@ def fail_if_models_did_not_load(context: Context): if model_type in context.model_load_errors: e = context.model_load_errors[model_type] raise Exception(f"Could not load the {model_type} model! Reason: " + e) - # concat 'e', don't use in format string (injection attack) + + +def download_default_models_if_necessary(): + for model_type, models in DEFAULT_MODELS.items(): + for model in models: + try: + download_if_necessary(model_type, model["file_name"], model["model_id"]) + except: + traceback.print_exc() + app.fail_and_die(fail_type="model_download", data=model_type) + + print(model_type, "model(s) found.") + + +def download_if_necessary(model_type: str, file_name: str, model_id: str): + model_path = os.path.join(app.MODELS_DIR, model_type, file_name) + expected_hash = get_model_info_from_db(model_type=model_type, model_id=model_id)["quick_hash"] + + other_models_exist = any_model_exists(model_type) + known_model_exists = os.path.exists(model_path) + known_model_is_corrupt = known_model_exists and hash_file_quick(model_path) != expected_hash + + if known_model_is_corrupt or (not other_models_exist and not known_model_exists): + print("> download", model_type, model_id) + download_model(model_type, model_id, download_base_dir=app.MODELS_DIR) def set_vram_optimizations(context: Context): @@ -181,6 +235,26 @@ def set_vram_optimizations(context: Context): return False +def migrate_legacy_model_location(): + 'Move the models inside the legacy "stable-diffusion" folder, to their respective folders' + + for model_type, models in DEFAULT_MODELS.items(): + for model in models: + file_name = model["file_name"] + legacy_path = os.path.join(app.SD_DIR, file_name) + if os.path.exists(legacy_path): + shutil.move(legacy_path, os.path.join(app.MODELS_DIR, model_type, file_name)) + + +def any_model_exists(model_type: str) -> bool: + extensions = MODEL_EXTENSIONS.get(model_type, []) + for ext in extensions: + if any(glob(f"{app.MODELS_DIR}/{model_type}/**/*{ext}", recursive=True)): + return True + + return False + + def set_clip_skip(context: Context, task_data: TaskData): clip_skip = task_data.clip_skip @@ -238,17 +312,12 @@ def is_malicious_model(file_path): def getModels(): models = { - "active": { - "stable-diffusion": "sd-v1-4", - "vae": "", - "hypernetwork": "", - "lora": "", - }, "options": { "stable-diffusion": ["sd-v1-4"], "vae": [], "hypernetwork": [], "lora": [], + "codeformer": ["codeformer"], }, } @@ -309,9 +378,4 @@ def getModels(): if models_scanned > 0: log.info(f"[green]Scanned {models_scanned} models. Nothing infected[/]") - # legacy - custom_weight_path = os.path.join(app.SD_DIR, "custom-model.ckpt") - if os.path.exists(custom_weight_path): - models["options"]["stable-diffusion"].append("custom-model") - return models diff --git a/ui/easydiffusion/renderer.py b/ui/easydiffusion/renderer.py index e2dae34f..a57dfc6c 100644 --- a/ui/easydiffusion/renderer.py +++ b/ui/easydiffusion/renderer.py @@ -7,10 +7,12 @@ from easydiffusion import device_manager from easydiffusion.types import GenerateImageRequest from easydiffusion.types import Image as ResponseImage from easydiffusion.types import Response, TaskData, UserInitiatedStop +from easydiffusion.model_manager import DEFAULT_MODELS, resolve_model_to_use from easydiffusion.utils import get_printable_request, log, save_images_to_disk from sdkit import Context from sdkit.filter import apply_filters from sdkit.generate import generate_images +from sdkit.models import load_model from sdkit.utils import ( diffusers_latent_samples_to_images, gc, @@ -34,6 +36,7 @@ def init(device): context.temp_images = {} context.partial_x_samples = None context.model_load_errors = {} + context.enable_codeformer = True from easydiffusion import app @@ -156,32 +159,51 @@ def filter_images(req: GenerateImageRequest, task_data: TaskData, images: list, if user_stopped: return images - filters_to_apply = [] - filter_params = {} if task_data.block_nsfw: - filters_to_apply.append("nsfw_checker") - if task_data.use_face_correction and "gfpgan" in task_data.use_face_correction.lower(): - filters_to_apply.append("gfpgan") + images = apply_filters(context, "nsfw_checker", images) + + if task_data.use_face_correction and "codeformer" in task_data.use_face_correction.lower(): + default_realesrgan = DEFAULT_MODELS["realesrgan"][0]["file_name"] + prev_realesrgan_path = None + if task_data.codeformer_upscale_faces and default_realesrgan not in context.model_paths["realesrgan"]: + prev_realesrgan_path = context.model_paths["realesrgan"] + context.model_paths["realesrgan"] = resolve_model_to_use(default_realesrgan, "realesrgan") + load_model(context, "realesrgan") + + try: + images = apply_filters( + context, + "codeformer", + images, + upscale_faces=task_data.codeformer_upscale_faces, + codeformer_fidelity=task_data.codeformer_fidelity, + ) + finally: + if prev_realesrgan_path: + context.model_paths["realesrgan"] = prev_realesrgan_path + load_model(context, "realesrgan") + elif task_data.use_face_correction and "gfpgan" in task_data.use_face_correction.lower(): + images = apply_filters(context, "gfpgan", images) + if task_data.use_upscale: if "realesrgan" in task_data.use_upscale.lower(): - filters_to_apply.append("realesrgan") + images = apply_filters(context, "realesrgan", images, scale=task_data.upscale_amount) elif task_data.use_upscale == "latent_upscaler": - filters_to_apply.append("latent_upscaler") + images = apply_filters( + context, + "latent_upscaler", + images, + scale=task_data.upscale_amount, + latent_upscaler_options={ + "prompt": req.prompt, + "negative_prompt": req.negative_prompt, + "seed": req.seed, + "num_inference_steps": task_data.latent_upscaler_steps, + "guidance_scale": 0, + }, + ) - filter_params["latent_upscaler_options"] = { - "prompt": req.prompt, - "negative_prompt": req.negative_prompt, - "seed": req.seed, - "num_inference_steps": task_data.latent_upscaler_steps, - "guidance_scale": 0, - } - - filter_params["scale"] = task_data.upscale_amount - - if len(filters_to_apply) == 0: - return images - - return apply_filters(context, filters_to_apply, images, **filter_params) + return images def construct_response(images: list, seeds: list, task_data: TaskData, base_seed: int): diff --git a/ui/easydiffusion/server.py b/ui/easydiffusion/server.py index a1aab6c0..d8940bb5 100644 --- a/ui/easydiffusion/server.py +++ b/ui/easydiffusion/server.py @@ -15,6 +15,7 @@ from fastapi import FastAPI, HTTPException from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Extra from starlette.responses import FileResponse, JSONResponse, StreamingResponse +from pycloudflared import try_cloudflare log.info(f"started in {app.SD_DIR}") log.info(f"started at {datetime.datetime.now():%x %X}") @@ -113,6 +114,14 @@ def init(): def get_image(task_id: int, img_id: int): return get_image_internal(task_id, img_id) + @server_api.post("/tunnel/cloudflare/start") + def start_cloudflare_tunnel(req: dict): + return start_cloudflare_tunnel_internal(req) + + @server_api.post("/tunnel/cloudflare/stop") + def stop_cloudflare_tunnel(req: dict): + return stop_cloudflare_tunnel_internal(req) + @server_api.get("/") def read_root(): return FileResponse(os.path.join(app.SD_UI_DIR, "index.html"), headers=NOCACHE_HEADERS) @@ -211,6 +220,8 @@ def ping_internal(session_id: str = None): session = task_manager.get_cached_session(session_id, update_ttl=True) response["tasks"] = {id(t): t.status for t in session.tasks} response["devices"] = task_manager.get_devices() + if cloudflare.address != None: + response["cloudflare"] = cloudflare.address return JSONResponse(response, headers=NOCACHE_HEADERS) @@ -322,3 +333,47 @@ def get_image_internal(task_id: int, img_id: int): return StreamingResponse(img_data, media_type="image/jpeg") except KeyError as e: raise HTTPException(status_code=500, detail=str(e)) + +#---- Cloudflare Tunnel ---- +class CloudflareTunnel: + def __init__(self): + config = app.getConfig() + self.urls = None + self.port = config.get("net", {}).get("listen_port") + + def start(self): + if self.port: + self.urls = try_cloudflare(self.port) + + def stop(self): + if self.urls: + try_cloudflare.terminate(self.port) + self.urls = None + + @property + def address(self): + if self.urls: + return self.urls.tunnel + else: + return None + +cloudflare = CloudflareTunnel() + +def start_cloudflare_tunnel_internal(req: dict): + try: + cloudflare.start() + log.info(f"- Started cloudflare tunnel. Using address: {cloudflare.address}") + return JSONResponse({"address":cloudflare.address}) + except Exception as e: + log.error(str(e)) + log.error(traceback.format_exc()) + return HTTPException(status_code=500, detail=str(e)) + +def stop_cloudflare_tunnel_internal(req: dict): + try: + cloudflare.stop() + except Exception as e: + log.error(str(e)) + log.error(traceback.format_exc()) + return HTTPException(status_code=500, detail=str(e)) + diff --git a/ui/easydiffusion/types.py b/ui/easydiffusion/types.py index e4426714..abf8db29 100644 --- a/ui/easydiffusion/types.py +++ b/ui/easydiffusion/types.py @@ -23,7 +23,7 @@ class GenerateImageRequest(BaseModel): sampler_name: str = None # "ddim", "plms", "heun", "euler", "euler_a", "dpm2", "dpm2_a", "lms" hypernetwork_strength: float = 0 lora_alpha: float = 0 - tiling: str = "none" # "none", "x", "y", "xy" + tiling: str = "none" # "none", "x", "y", "xy" class TaskData(BaseModel): @@ -51,6 +51,8 @@ class TaskData(BaseModel): stream_image_progress: bool = False stream_image_progress_interval: int = 5 clip_skip: bool = False + codeformer_upscale_faces: bool = False + codeformer_fidelity: float = 0.5 class MergeRequest(BaseModel): diff --git a/ui/index.html b/ui/index.html index 21ec2550..0c4386de 100644 --- a/ui/index.html +++ b/ui/index.html @@ -30,7 +30,7 @@

Easy Diffusion - v2.5.39 + v2.5.41

@@ -227,7 +227,10 @@ -
+ + -2 2   +
+ @@ -263,7 +266,13 @@
@@ -356,10 +365,16 @@

System Settings

-
+



+
+

Share Easy Diffusion

+
+
+
+

System Info

@@ -534,7 +549,8 @@ async function init() { SD.init({ events: { statusChange: setServerStatus, - idle: onIdle + idle: onIdle, + ping: tunnelUpdate } }) diff --git a/ui/media/css/auto-save.css b/ui/media/css/auto-save.css index 80aa48d8..119a7e10 100644 --- a/ui/media/css/auto-save.css +++ b/ui/media/css/auto-save.css @@ -69,13 +69,15 @@ } .parameters-table > div:first-child { - border-radius: 12px 12px 0px 0px; + border-top-left-radius: 12px; + border-top-right-radius: 12px; } .parameters-table > div:last-child { - border-radius: 0px 0px 12px 12px; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; } .parameters-table .fa-fire { color: #F7630C; -} \ No newline at end of file +} diff --git a/ui/media/css/image-editor.css b/ui/media/css/image-editor.css index ea8112e3..ba046383 100644 --- a/ui/media/css/image-editor.css +++ b/ui/media/css/image-editor.css @@ -96,7 +96,7 @@ .editor-controls-center { /* background: var(--background-color2); */ - flex: 1; + flex: 0; display: flex; justify-content: center; align-items: center; @@ -105,6 +105,8 @@ .editor-controls-center > div { position: relative; background: black; + margin: 20pt; + margin-top: 40pt; } .editor-controls-center canvas { @@ -164,8 +166,10 @@ margin: var(--popup-margin); padding: var(--popup-padding); min-height: calc(99h - (2 * var(--popup-margin))); - max-width: none; + max-width: fit-content; min-width: fit-content; + margin-left: auto; + margin-right: auto; } .image-editor-popup h1 { diff --git a/ui/media/css/main.css b/ui/media/css/main.css index 8f4f49fa..9df38f9a 100644 --- a/ui/media/css/main.css +++ b/ui/media/css/main.css @@ -1303,12 +1303,35 @@ body.wait-pause { display:none !important; } -#latent_upscaler_settings { +.sub-settings { padding-top: 3pt; padding-bottom: 3pt; padding-left: 5pt; } +#cloudflare-address { + background-color: var(--background-color3); + padding: 6px; + border-radius: var(--input-border-radius); + border: var(--input-border-size) solid var(--input-border-color); + margin-top: 0.2em; + margin-bottom: 0.2em; + display: inline-block; +} + +#copy-cloudflare-address { + padding: 4px 8px; + margin-left: 0.5em; +} + +.expandedSettingRow { + background: var(--background-color1); + width: 95%; + border-radius: 4pt; + margin-top: 5pt; + margin-bottom: 3pt; +} + /* TOAST NOTIFICATIONS */ .toast-notification { position: fixed; @@ -1322,7 +1345,7 @@ body.wait-pause { box-shadow: 0 0 10px rgba(0, 0, 0, 0.5); z-index: 9999; animation: slideInRight 0.5s ease forwards; - transition: bottom 0.5s ease; // Add a transition to smoothly reposition the toasts + transition: bottom 0.5s ease; /* Add a transition to smoothly reposition the toasts */ } .toast-notification-error { diff --git a/ui/media/js/engine.js b/ui/media/js/engine.js index e60409f1..f3ce0551 100644 --- a/ui/media/js/engine.js +++ b/ui/media/js/engine.js @@ -186,6 +186,7 @@ const EVENT_TASK_START = "taskStart" const EVENT_TASK_END = "taskEnd" const EVENT_TASK_ERROR = "task_error" + const EVENT_PING = "ping" const EVENT_UNEXPECTED_RESPONSE = "unexpectedResponse" const EVENTS_TYPES = [ EVENT_IDLE, @@ -196,6 +197,7 @@ EVENT_TASK_START, EVENT_TASK_END, EVENT_TASK_ERROR, + EVENT_PING, EVENT_UNEXPECTED_RESPONSE, ] @@ -240,6 +242,7 @@ setServerStatus("error", "offline") return false } + // Set status switch (serverState.status) { case ServerStates.init: @@ -261,6 +264,7 @@ break } serverState.time = Date.now() + await eventSource.fireEvent(EVENT_PING, serverState) return true } catch (e) { console.error(e) diff --git a/ui/media/js/main.js b/ui/media/js/main.js index 8628732b..b44af102 100644 --- a/ui/media/js/main.js +++ b/ui/media/js/main.js @@ -87,13 +87,15 @@ let promptStrengthField = document.querySelector("#prompt_strength") let samplerField = document.querySelector("#sampler_name") let samplerSelectionContainer = document.querySelector("#samplerSelection") let useFaceCorrectionField = document.querySelector("#use_face_correction") -let gfpganModelField = new ModelDropdown(document.querySelector("#gfpgan_model"), "gfpgan") +let gfpganModelField = new ModelDropdown(document.querySelector("#gfpgan_model"), ["gfpgan", "codeformer"], "", false) let useUpscalingField = document.querySelector("#use_upscale") let upscaleModelField = document.querySelector("#upscale_model") let upscaleAmountField = document.querySelector("#upscale_amount") let latentUpscalerSettings = document.querySelector("#latent_upscaler_settings") let latentUpscalerStepsSlider = document.querySelector("#latent_upscaler_steps_slider") let latentUpscalerStepsField = document.querySelector("#latent_upscaler_steps") +let codeformerFidelitySlider = document.querySelector("#codeformer_fidelity_slider") +let codeformerFidelityField = document.querySelector("#codeformer_fidelity") let stableDiffusionModelField = new ModelDropdown(document.querySelector("#stable_diffusion_model"), "stable-diffusion") let clipSkipField = document.querySelector("#clip_skip") let tilingField = document.querySelector("#tiling") @@ -270,7 +272,9 @@ function shiftOrConfirm(e, prompt, fn) { confirm( 'Tip: To skip this dialog, use shift-click or disable the "Confirm dangerous actions" setting in the Settings tab.', prompt, - () => { fn(e) } + () => { + fn(e) + } ) } } @@ -1261,6 +1265,11 @@ function getCurrentUserRequest() { } if (useFaceCorrectionField.checked) { newTask.reqBody.use_face_correction = gfpganModelField.value + + if (gfpganModelField.value.includes("codeformer")) { + newTask.reqBody.codeformer_upscale_faces = document.querySelector("#codeformer_upscale_faces").checked + newTask.reqBody.codeformer_fidelity = 1 - parseFloat(codeformerFidelityField.value) + } } if (useUpscalingField.checked) { newTask.reqBody.use_upscale = upscaleModelField.value @@ -1574,24 +1583,43 @@ metadataOutputFormatField.disabled = !saveToDiskField.checked gfpganModelField.disabled = !useFaceCorrectionField.checked useFaceCorrectionField.addEventListener("change", function(e) { gfpganModelField.disabled = !this.checked + + onFixFaceModelChange() }) +function onFixFaceModelChange() { + let codeformerSettings = document.querySelector("#codeformer_settings") + if (gfpganModelField.value === "codeformer" && !gfpganModelField.disabled) { + codeformerSettings.classList.remove("displayNone") + codeformerSettings.classList.add("expandedSettingRow") + } else { + codeformerSettings.classList.add("displayNone") + codeformerSettings.classList.remove("expandedSettingRow") + } +} +gfpganModelField.addEventListener("change", onFixFaceModelChange) +onFixFaceModelChange() + upscaleModelField.disabled = !useUpscalingField.checked upscaleAmountField.disabled = !useUpscalingField.checked useUpscalingField.addEventListener("change", function(e) { upscaleModelField.disabled = !this.checked upscaleAmountField.disabled = !this.checked + + onUpscaleModelChange() }) function onUpscaleModelChange() { let upscale4x = document.querySelector("#upscale_amount_4x") - if (upscaleModelField.value === "latent_upscaler") { + if (upscaleModelField.value === "latent_upscaler" && !upscaleModelField.disabled) { upscale4x.disabled = true upscaleAmountField.value = "2" latentUpscalerSettings.classList.remove("displayNone") + latentUpscalerSettings.classList.add("expandedSettingRow") } else { upscale4x.disabled = false latentUpscalerSettings.classList.add("displayNone") + latentUpscalerSettings.classList.remove("expandedSettingRow") } } upscaleModelField.addEventListener("change", onUpscaleModelChange) @@ -1606,6 +1634,27 @@ document.onkeydown = function(e) { } } +/********************* CodeFormer Fidelity **************************/ +function updateCodeformerFidelity() { + codeformerFidelityField.value = codeformerFidelitySlider.value / 10 + codeformerFidelityField.dispatchEvent(new Event("change")) +} + +function updateCodeformerFidelitySlider() { + if (codeformerFidelityField.value < 0) { + codeformerFidelityField.value = 0 + } else if (codeformerFidelityField.value > 1) { + codeformerFidelityField.value = 1 + } + + codeformerFidelitySlider.value = codeformerFidelityField.value * 10 + codeformerFidelitySlider.dispatchEvent(new Event("change")) +} + +codeformerFidelitySlider.addEventListener("input", updateCodeformerFidelity) +codeformerFidelityField.addEventListener("input", updateCodeformerFidelitySlider) +updateCodeformerFidelity() + /********************* Latent Upscaler Steps **************************/ function updateLatentUpscalerSteps() { latentUpscalerStepsField.value = latentUpscalerStepsSlider.value @@ -1704,10 +1753,10 @@ function updateLoraAlpha() { } function updateLoraAlphaSlider() { - if (loraAlphaField.value < 0) { - loraAlphaField.value = 0 - } else if (loraAlphaField.value > 1) { - loraAlphaField.value = 1 + if (loraAlphaField.value < -2) { + loraAlphaField.value = -2 + } else if (loraAlphaField.value > 2) { + loraAlphaField.value = 2 } loraAlphaSlider.value = loraAlphaField.value * 100 @@ -1958,6 +2007,38 @@ resumeBtn.addEventListener("click", function() { document.body.classList.remove("wait-pause") }) +function tunnelUpdate(event) { + if ("cloudflare" in event) { + document.getElementById("cloudflare-off").classList.add("displayNone") + document.getElementById("cloudflare-on").classList.remove("displayNone") + cloudflareAddressField.innerHTML = event.cloudflare + document.getElementById("toggle-cloudflare-tunnel").innerHTML = "Stop" + } else { + document.getElementById("cloudflare-on").classList.add("displayNone") + document.getElementById("cloudflare-off").classList.remove("displayNone") + document.getElementById("toggle-cloudflare-tunnel").innerHTML = "Start" + } +} + +document.getElementById("toggle-cloudflare-tunnel").addEventListener("click", async function() { + let command = "stop" + if (document.getElementById("toggle-cloudflare-tunnel").innerHTML == "Start") { + command = "start" + } + showToast(`Cloudflare tunnel ${command} initiated. Please wait.`) + + let res = await fetch("/tunnel/cloudflare/" + command, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }) + res = await res.json() + + console.log(`Cloudflare tunnel ${command} result:`, res) +}) + /* Pause function */ document.querySelectorAll(".tab").forEach(linkTabContents) diff --git a/ui/media/js/parameters.js b/ui/media/js/parameters.js index 373de58d..2f915eeb 100644 --- a/ui/media/js/parameters.js +++ b/ui/media/js/parameters.js @@ -11,6 +11,12 @@ var ParameterType = { custom: "custom", } +/** + * Element shortcuts + */ +let parametersTable = document.querySelector("#system-settings-table") +let networkParametersTable = document.querySelector("#system-settings-network-table") + /** * JSDoc style * @typedef {object} Parameter @@ -186,6 +192,7 @@ var PARAMETERS = [ icon: "fa-network-wired", default: true, saveInAppConfig: true, + table: networkParametersTable, }, { id: "listen_port", @@ -198,6 +205,7 @@ var PARAMETERS = [ return `` }, saveInAppConfig: true, + table: networkParametersTable, }, { id: "use_beta_channel", @@ -218,6 +226,21 @@ var PARAMETERS = [ default: false, saveInAppConfig: true, }, + { + id: "cloudflare", + type: ParameterType.custom, + label: "Cloudflare tunnel", + note: `Create a VPN tunnel to share your Easy Diffusion instance with your friends. This will + generate a web server address on the public Internet for your Easy Diffusion instance. +
This Easy Diffusion server is available on the Internet using the + address:
+ Anyone knowing this address can access your server. The address of your server will change each time + you share a session.
+ Uses Cloudflare services.`, + icon: ["fa-brands", "fa-cloudflare"], + render: () => '', + table: networkParametersTable, + } ] function getParameterSettingsEntry(id) { @@ -266,7 +289,6 @@ function getParameterElement(parameter) { } } -let parametersTable = document.querySelector("#system-settings .parameters-table") /** * fill in the system settings popup table * @param {Array | undefined} parameters @@ -293,7 +315,10 @@ function initParameters(parameters) { noteElements.push(noteElement) } - const icon = parameter.icon ? [createElement("i", undefined, ["fa", parameter.icon])] : [] + if (typeof(parameter.icon) == "string") { + parameter.icon = [parameter.icon] + } + const icon = parameter.icon ? [createElement("i", undefined, ["fa", ...parameter.icon])] : [] const label = typeof parameter.label === "function" ? parameter.label(parameter) : parameter.label const labelElement = createElement("label", { for: parameter.id }) @@ -313,7 +338,13 @@ function initParameters(parameters) { elementWrapper, ] ) - parametersTable.appendChild(newrow) + + let p = parametersTable + if (parameter.table) { + p = parameter.table + } + p.appendChild(newrow) + parameter.settingsEntry = newrow }) } @@ -667,8 +698,25 @@ saveSettingsBtn.addEventListener("click", function() { }) const savePromise = changeAppConfig(updateAppConfigRequest) + showToast("Settings saved") saveSettingsBtn.classList.add("active") Promise.all([savePromise, asyncDelay(300)]).then(() => saveSettingsBtn.classList.remove("active")) }) +listenToNetworkField.addEventListener("change", debounce( ()=>{ + saveSettingsBtn.click() +}, 1000)) + +listenPortField.addEventListener("change", debounce( ()=>{ + saveSettingsBtn.click() +}, 1000)) + +let copyCloudflareAddressBtn = document.querySelector("#copy-cloudflare-address") +let cloudflareAddressField = document.getElementById("cloudflare-address") + +copyCloudflareAddressBtn.addEventListener("click", (e) => { + navigator.clipboard.writeText(cloudflareAddressField.innerHTML) + showToast("Copied server address to clipboard") +}) + document.addEventListener("system_info_update", (e) => setDeviceInfo(e.detail)) diff --git a/ui/media/js/searchable-models.js b/ui/media/js/searchable-models.js index 9a723d15..7bdb176a 100644 --- a/ui/media/js/searchable-models.js +++ b/ui/media/js/searchable-models.js @@ -38,6 +38,8 @@ class ModelDropdown { noneEntry //= '' modelFilterInitialized //= undefined + sorted //= true + /* MIMIC A REGULAR INPUT FIELD */ get parentElement() { return this.modelFilter.parentElement @@ -83,21 +85,34 @@ class ModelDropdown { /* SEARCHABLE INPUT */ - constructor(input, modelKey, noneEntry = "") { + constructor(input, modelKey, noneEntry = "", sorted = true) { this.modelFilter = input this.noneEntry = noneEntry this.modelKey = modelKey + this.sorted = sorted if (modelsOptions !== undefined) { // reuse models from cache (only useful for plugins, which are loaded after models) - this.inputModels = modelsOptions[this.modelKey] + this.inputModels = [] + let modelKeys = Array.isArray(this.modelKey) ? this.modelKey : [this.modelKey] + for (let i = 0; i < modelKeys.length; i++) { + let key = modelKeys[i] + let k = Array.isArray(modelsOptions[key]) ? modelsOptions[key] : [modelsOptions[key]] + this.inputModels.push(...k) + } this.populateModels() } document.addEventListener( "refreshModels", this.bind(function(e) { // reload the models - this.inputModels = modelsOptions[this.modelKey] + this.inputModels = [] + let modelKeys = Array.isArray(this.modelKey) ? this.modelKey : [this.modelKey] + for (let i = 0; i < modelKeys.length; i++) { + let key = modelKeys[i] + let k = Array.isArray(modelsOptions[key]) ? modelsOptions[key] : [modelsOptions[key]] + this.inputModels.push(...k) + } this.populateModels() }, this) ) @@ -554,11 +569,15 @@ class ModelDropdown { }) const childFolderNames = Array.from(foldersMap.keys()) - this.sortStringArray(childFolderNames) + if (this.sorted) { + this.sortStringArray(childFolderNames) + } const folderElements = childFolderNames.map((name) => foldersMap.get(name)) const modelNames = Array.from(modelsMap.keys()) - this.sortStringArray(modelNames) + if (this.sorted) { + this.sortStringArray(modelNames) + } const modelElements = modelNames.map((name) => modelsMap.get(name)) if (modelElements.length && folderName) { diff --git a/ui/media/js/utils.js b/ui/media/js/utils.js index 6ddb0ae6..871ba714 100644 --- a/ui/media/js/utils.js +++ b/ui/media/js/utils.js @@ -402,12 +402,12 @@ function debounce(func, wait, immediate) { function preventNonNumericalInput(e) { e = e || window.event - let charCode = typeof e.which == "undefined" ? e.keyCode : e.which - let charStr = String.fromCharCode(charCode) - let re = e.target.getAttribute("pattern") || "^[0-9]+$" - re = new RegExp(re) + const charCode = typeof e.which == "undefined" ? e.keyCode : e.which + const charStr = String.fromCharCode(charCode) + const newInputValue = `${e.target.value}${charStr}` + const re = new RegExp(e.target.getAttribute("pattern") || "^[0-9]+$") - if (!charStr.match(re)) { + if (!re.test(charStr) && !re.test(newInputValue)) { e.preventDefault() } } diff --git a/ui/plugins/ui/tiled-image-download.plugin.js b/ui/plugins/ui/tiled-image-download.plugin.js new file mode 100644 index 00000000..0fe00c2e --- /dev/null +++ b/ui/plugins/ui/tiled-image-download.plugin.js @@ -0,0 +1,326 @@ +;(function(){ + "use strict"; + const PAPERSIZE = [ + {id: "a3p", width: 297, height: 420, unit: "mm"}, + {id: "a3l", width: 420, height: 297, unit: "mm"}, + {id: "a4p", width: 210, height: 297, unit: "mm"}, + {id: "a4l", width: 297, height: 210, unit: "mm"}, + {id: "ll", width: 279, height: 216, unit: "mm"}, + {id: "lp", width: 216, height: 279, unit: "mm"}, + {id: "hd", width: 1920, height: 1080, unit: "pixels"}, + {id: "4k", width: 3840, height: 2160, unit: "pixels"}, + ] + + // ---- Register plugin + PLUGINS['IMAGE_INFO_BUTTONS'].push({ + html: ' Download tiled image', + on_click: onDownloadTiledImage, + filter: (req, img) => req.tiling != "none", + }) + + var thisImage + + function onDownloadTiledImage(req, img) { + document.getElementById("download-tiled-image-dialog").showModal() + thisImage = new Image() + thisImage.src = img.src + thisImage.dataset["prompt"] = img.dataset["prompt"] + } + + // ---- Add HTML + document.getElementById('container').lastElementChild.insertAdjacentHTML("afterend", + ` +

Download tiled image

+
+
+
+ + Number of tiles + + + Image dimensions + +
+
+
+
+ + +
+
+
+
+
+ + + +
+
+ +
+
+ Some standard sizes:
+
+
+
+ +
+
+
+
+
+
+
+ + Tile placement + +
+
+
+
+ +
+ +
+ +
+
+
+
+
+
+ +
+
+ +
+
+ +
+
+
`) + + let downloadTiledImageDialog = document.getElementById("download-tiled-image-dialog") + let dtim1_width = document.getElementById("dtim1-width") + let dtim1_height = document.getElementById("dtim1-height") + let dtim2_width = document.getElementById("dtim2-width") + let dtim2_height = document.getElementById("dtim2-height") + let dtim2_unit = document.getElementById("dtim2-unit") + let dtim2_dpi = document.getElementById("dtim2-dpi") + let tabTiledTilesOptions = document.getElementById("tab-image-tiles") + let tabTiledSizeOptions = document.getElementById("tab-image-size") + + linkTabContents(tabTiledTilesOptions) + linkTabContents(tabTiledSizeOptions) + + prettifyInputs(downloadTiledImageDialog) + + // ---- Predefined image dimensions + PAPERSIZE.forEach( function(p) { + document.getElementById("dtim2-" + p.id).addEventListener("click", (e) => { + dtim2_unit.value = p.unit + dtim2_width.value = p.width + dtim2_height.value = p.height + }) + }) + + // ---- Close popup + document.getElementById("dti-cancel").addEventListener("click", (e) => downloadTiledImageDialog.close()) + downloadTiledImageDialog.addEventListener('click', function (event) { + var rect = downloadTiledImageDialog.getBoundingClientRect(); + var isInDialog=(rect.top <= event.clientY && event.clientY <= rect.top + rect.height + && rect.left <= event.clientX && event.clientX <= rect.left + rect.width); + if (!isInDialog) { + downloadTiledImageDialog.close(); + } + }); + + // ---- Stylesheet + const styleSheet = document.createElement("style") + styleSheet.textContent = ` + dialog { + background: var(--background-color2); + color: var(--text-color); + border-radius: 7px; + border: 1px solid var(--background-color3); + } + + dialog::backdrop { + background: rgba(0, 0, 0, 0.5); + } + + + button[disabled] { + opacity: 0.5; + } + + .method-2-dpi { + margin-top: 1em; + margin-bottom: 1em; + } + + .method-2-paper button { + width: 10em; + padding: 4px; + margin: 4px; + } + + .download-tiled-image .tab-content { + background: var(--background-color1); + border-radius: 3pt; + } + + .dtim-container { display: grid; + grid-template-columns: auto auto; + grid-template-rows: auto auto; + gap: 1em 0px; + grid-auto-flow: row; + grid-template-areas: + "dtim-tab dtim-tab dtim-plc" + "dtim-ok dtim-newtab dtim-cancel"; + } + + .download-tiled-image-top { + justify-self: center; + grid-area: dtim-tab; + } + + .download-tiled-image-placement { + justify-self: center; + grid-area: dtim-plc; + margin-left: 1em; + } + + .dtim-ok { + justify-self: center; + align-self: start; + grid-area: dtim-ok; + } + + .dtim-newtab { + justify-self: center; + align-self: start; + grid-area: dtim-newtab; + } + + .dtim-cancel { + justify-self: center; + align-self: start; + grid-area: dtim-cancel; + } + + #tab-content-image-placement img { + margin: 4px; + opacity: 0.3; + border: solid 2px var(--background-color1); + } + + #tab-content-image-placement img:hover { + margin: 4px; + opacity: 1; + border: solid 2px var(--accent-color); + filter: brightness(2); + } + + #tab-content-image-placement img.active { + margin: 4px; + opacity: 1; + border: solid 2px var(--background-color1); + } + + ` + document.head.appendChild(styleSheet) + + // ---- Placement widget + + function updatePlacementWidget(event) { + document.querySelector("#tab-content-image-placement img.active").classList.remove("active") + event.target.classList.add("active") + } + + document.querySelectorAll("#tab-content-image-placement img").forEach( + (i) => i.addEventListener("click", updatePlacementWidget) + ) + + function getPlacement() { + return document.querySelector("#tab-content-image-placement img.active").id.substr(5) + } + + // ---- Make the image + function downloadTiledImage(image, width, height, offsetX=0, offsetY=0, new_tab=false) { + + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d') + + const w = image.width + const h = image.height + + for (var x = offsetX; x < width; x += w) { + for (var y = offsetY; y < height; y += h) { + context.drawImage(image, x, y, w, h) + } + } + if (new_tab) { + var newTab = window.open("") + newTab.document.write(`${width}×${height}, "${image.dataset["prompt"]}"`) + } else { + const link = document.createElement('a') + link.href = canvas.toDataURL() + link.download = image.dataset["prompt"].replace(/[^a-zA-Z0-9]+/g, "-").substr(0,22)+crypto.randomUUID()+".png" + link.click() + } + } + + function onDownloadTiledImageClick(e, newtab=false) { + var width, height, offsetX, offsetY + + if (isTabActive(tabTiledTilesOptions)) { + width = thisImage.width * dtim1_width.value + height = thisImage.height * dtim1_height.value + } else { + if ( dtim2_unit.value == "pixels" ) { + width = dtim2_width.value + height= dtim2_height.value + } else if ( dtim2_unit.value == "mm" ) { + width = Math.floor( dtim2_width.value * dtim2_dpi.value / 25.4 ) + height = Math.floor( dtim2_height.value * dtim2_dpi.value / 25.4 ) + } else { // inch + width = Math.floor( dtim2_width.value * dtim2_dpi.value ) + height = Math.floor( dtim2_height.value * dtim2_dpi.value ) + } + } + + var placement = getPlacement() + if (placement == "1tl") { + offsetX = 0 + offsetY = 0 + } else if (placement == "1tr") { + offsetX = width - thisImage.width * Math.ceil( width / thisImage.width ) + offsetY = 0 + } else if (placement == "1bl") { + offsetX = 0 + offsetY = height - thisImage.height * Math.ceil( height / thisImage.height ) + } else if (placement == "1br") { + offsetX = width - thisImage.width * Math.ceil( width / thisImage.width ) + offsetY = height - thisImage.height * Math.ceil( height / thisImage.height ) + } else if (placement == "4center") { + offsetX = width/2 - thisImage.width * Math.ceil( width/2 / thisImage.width ) + offsetY = height/2 - thisImage.height * Math.ceil( height/2 / thisImage.height ) + } else if (placement == "1center") { + offsetX = width/2 - thisImage.width/2 - thisImage.width * Math.ceil( (width/2 - thisImage.width/2) / thisImage.width ) + offsetY = height/2 - thisImage.height/2 - thisImage.height * Math.ceil( (height/2 - thisImage.height/2) / thisImage.height ) + } + downloadTiledImage(thisImage, width, height, offsetX, offsetY, newtab) + downloadTiledImageDialog.close() + } + + document.getElementById("dti-ok").addEventListener("click", onDownloadTiledImageClick) + document.getElementById("dti-newtab").addEventListener("click", (e) => onDownloadTiledImageClick(e,true)) + +})()