From 89a7ea6a3f2ebea065e3e1203c166b1fa984f523 Mon Sep 17 00:00:00 2001 From: Vladimir Mandic Date: Sat, 8 Jul 2023 09:49:41 -0400 Subject: [PATCH] overal quality fixes --- CHANGELOG.md | 6 ++-- DIFFUSERS.md | 28 ----------------- extensions-builtin/LDSR/sd_hijack_ddpm_v1.py | 3 +- installer.py | 4 ++- javascript/black-orange.css | 33 +++++++++++--------- modules/devices.py | 15 +++++++++ modules/lora_diffusers.py | 2 +- modules/modelloader.py | 5 ++- modules/models/diffusion/ddpm_edit.py | 4 +-- modules/processing.py | 21 +++++++------ modules/sd_models.py | 13 ++++---- modules/ui_models.py | 4 +-- requirements.txt | 2 +- wiki | 2 +- 14 files changed, 68 insertions(+), 74 deletions(-) delete mode 100644 DIFFUSERS.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 18d85957b..dfdf9f26d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,13 +1,13 @@ # Change Log for SD.Next -## Update for 07/07/2023 +## Update for 07/08/2023 - add pan & zoom controls (touch and mouse) to image viewer (lightbox) +- cache extra networks between tabs + this should result in neat 2x speedup on building extra networks - add settings -> extra networks -> do not automatically build extra network pages speeds up app start if you have a lot of extra networks and you want to build them manually when needed - extra network ui tweaks -- cache extra networks between tabs - this should result in neat 2x speedup on building extra networks - merge experimental diffusers support covered in details in a separate post diff --git a/DIFFUSERS.md b/DIFFUSERS.md deleted file mode 100644 index db052527c..000000000 --- a/DIFFUSERS.md +++ /dev/null @@ -1,28 +0,0 @@ -# Diffusers - -## Install - -initial support merged into `dev` branch - -- download from branch and start as normal: - > git clone https://github.com/vladmandic/automatic -b dev diffusers - > cd diffusers - > webui --debug --backend diffusers - -- to go back to standard execution pipeline, start with - > webui --debug --backend original - -- To update repo, do not use `--upgrade` flag, use manual `git pull` instead - -## Notes - -All notes have moved to [Wiki page](https://github.com/vladmandic/automatic/wiki/Diffusers) - -## TODO - -- VAE -- Refiner with medvram/lowvram -- SD-XL from safetensors -- Hires fix -- Callbacks -- Stop/Skip diff --git a/extensions-builtin/LDSR/sd_hijack_ddpm_v1.py b/extensions-builtin/LDSR/sd_hijack_ddpm_v1.py index bc2af045a..053be8290 100644 --- a/extensions-builtin/LDSR/sd_hijack_ddpm_v1.py +++ b/extensions-builtin/LDSR/sd_hijack_ddpm_v1.py @@ -162,7 +162,6 @@ class DDPMV1(pl.LightningModule): lvlb_weights = 0.5 * np.sqrt(torch.Tensor(alphas_cumprod)) / (2. * 1 - torch.Tensor(alphas_cumprod)) else: raise NotImplementedError("mu not supported") - # TODO how to choose this term lvlb_weights[0] = lvlb_weights[1] self.register_buffer('lvlb_weights', lvlb_weights, persistent=False) assert not torch.isnan(self.lvlb_weights).all() @@ -872,7 +871,7 @@ class LatentDiffusionV1(DDPMV1): assert c is not None if self.cond_stage_trainable: c = self.get_learned_conditioning(c) - if self.shorten_cond_schedule: # TODO: drop this option + if self.shorten_cond_schedule: tc = self.cond_ids[t].to(self.device) c = self.q_sample(x_start=c, t=tc, noise=torch.randn_like(c.float())) return self.p_losses(x, c, t, *args, **kwargs) diff --git a/installer.py b/installer.py index 07de36730..795e81272 100644 --- a/installer.py +++ b/installer.py @@ -212,8 +212,10 @@ def git(arg: str, folder: str = None, ignore: bool = False): # switch to main branch as head can get detached def branch(folder): + if args.experimental: + return None if not os.path.exists(os.path.join(folder, '.git')): - return + return None b = git('branch', folder) if 'main' in b: b = 'main' diff --git a/javascript/black-orange.css b/javascript/black-orange.css index 09cd5bb0f..20e9f7ec6 100644 --- a/javascript/black-orange.css +++ b/javascript/black-orange.css @@ -18,28 +18,35 @@ --primary-900: #7c2d12; --primary-950: #6c2e12; } +.light, .dark { + --radius-lg: 2px; + --radius-sm: 1px; + --spacing-md: 5px; +} + html { font-size: var(--font-size); } body, button, input, select, textarea { font-family: var(--font);} button { font-size: 1.2rem; } img { background-color: var(--background-color); } input[type=range] { height: 18px; appearance: none; margin-top: 0; min-width: 160px; background-color: var(--background-color); width: 100%; background: transparent; } -input[type=range]::-webkit-slider-runnable-track { width: 100%; height: 18px; cursor: pointer; box-shadow: 2px 2px 3px #111111; background: #50555C; border-radius: 2px; border: 0px solid #222222; } -input[type=range]::-moz-range-track { width: 100%; height: 18px; cursor: pointer; box-shadow: 2px 2px 3px #111111; background: #50555C; border-radius: 2px; border: 0px solid #222222; } -input[type=range]::-webkit-slider-thumb { box-shadow: 2px 2px 3px #111111; border: 0px solid #000000; height: 18px; width: 40px; border-radius: 2px; background: var(--highlight-color); cursor: pointer; appearance: none; margin-top: 0px; } -input[type=range]::-moz-range-thumb { box-shadow: 2px 2px 3px #111111; border: 0px solid #000000; height: 18px; width: 40px; border-radius: 2px; background: var(--highlight-color); cursor: pointer; appearance: none; margin-top: 0px; } +input[type=range]::-webkit-slider-runnable-track { width: 100%; height: 18px; cursor: pointer; box-shadow: 2px 2px 3px #111111; background: #50555C; border-radius: var(--radius-lg); border: 0px solid #222222; } +input[type=range]::-moz-range-track { width: 100%; height: 18px; cursor: pointer; box-shadow: 2px 2px 3px #111111; background: #50555C; border-radius: var(--radius-lg); border: 0px solid #222222; } +input[type=range]::-webkit-slider-thumb { box-shadow: 2px 2px 3px #111111; border: 0px solid #000000; height: 18px; width: 40px; border-radius: var(--radius-lg); background: var(--highlight-color); cursor: pointer; appearance: none; margin-top: 0px; } +input[type=range]::-moz-range-thumb { box-shadow: 2px 2px 3px #111111; border: 0px solid #000000; height: 18px; width: 40px; border-radius: var(--radius-lg); background: var(--highlight-color); cursor: pointer; appearance: none; margin-top: 0px; } ::-webkit-scrollbar { width: 12px; } ::-webkit-scrollbar-track { background: #333333; } -::-webkit-scrollbar-thumb { background-color: var(--highlight-color); border-radius: 2px; border-width: 0; box-shadow: 2px 2px 3px #111111; } +::-webkit-scrollbar-thumb { background-color: var(--highlight-color); border-radius: var(--radius-lg); border-width: 0; box-shadow: 2px 2px 3px #111111; } div.form { border-width: 0; box-shadow: none; background: transparent; overflow: visible; gap: 0.5em; margin-bottom: 6px; } +div.compact { gap: 0.8em; } /* gradio style classes */ fieldset .gr-block.gr-box, label.block span { padding: 0; margin-top: -4px; } .border-2 { border-width: 0; } .border-b-2 { border-bottom-width: 2px; border-color: var(--highlight-color) !important; padding-bottom: 2px; margin-bottom: 8px; } .bg-white { color: lightyellow; background-color: var(--inactive-color); } -.gr-box { border-radius: 0 !important; background-color: #111111 !important; box-shadow: 2px 2px 3px #111111; border-width: 0; padding: 4px; margin: 12px 0px 12px 0px } +.gr-box { border-radius: var(--radius-sm) !important; background-color: #111111 !important; box-shadow: 2px 2px 3px #111111; border-width: 0; padding: 4px; margin: 12px 0px 12px 0px } .gr-button { font-weight: normal; box-shadow: 2px 2px 3px #111111; font-size: 0.8rem; min-width: 32px; min-height: 32px; padding: 3px; margin: 3px; } -.gr-check-radio { background-color: var(--inactive-color); border-width: 0; border-radius: 2px; box-shadow: 2px 2px 3px #111111; } +.gr-check-radio { background-color: var(--inactive-color); border-width: 0; border-radius: var(--radius-lg); box-shadow: 2px 2px 3px #111111; } .gr-check-radio:checked { background-color: var(--highlight-color); } .gr-compact { background-color: var(--background-color); } .gr-form { border-width: 0; } @@ -66,8 +73,8 @@ svg.feather.feather-image, .feather .feather-image { display: none } #tab_extensions table thead { background-color: var(--neutral-700); } /* automatic style classes */ -.progressDiv { border-radius: 0 !important; position: fixed; top: 44px; right: 26px; max-width: 262px; height: 48px; z-index: 99; box-shadow: var(--button-shadow); } -.progressDiv .progress { border-radius: 0 !important; background: var(--highlight-color); line-height: 3rem; height: 48px; } +.progressDiv { border-radius: var(--radius-sm) !important; position: fixed; top: 44px; right: 26px; max-width: 262px; height: 48px; z-index: 99; box-shadow: var(--button-shadow); } +.progressDiv .progress { border-radius: var(--radius-lg) !important; background: var(--highlight-color); line-height: 3rem; height: 48px; } .gallery-item { box-shadow: none !important; } .performance { color: #888; } .extra-networks { border-left: 2px solid var(--highlight-color) !important; padding-left: 4px; } @@ -77,15 +84,14 @@ svg.feather.feather-image, .feather .feather-image { display: none } #div.gradio-container { overflow-x: hidden; } #img2img_label_copy_to_img2img { font-weight: normal; } #txt2img_prompt, #txt2img_neg_prompt, #img2img_prompt, #img2img_neg_prompt { background-color: var(--background-color); box-shadow: 4px 4px 4px 0px #333333 !important; } -#txt2img_prompt > label > textarea, #txt2img_neg_prompt > label > textarea, #img2img_prompt > label > textarea, #img2img_neg_prompt > label > textarea { font-size: 1.2rem; } +#txt2img_prompt > label > textarea, #txt2img_neg_prompt > label > textarea, #img2img_prompt > label > textarea, #img2img_neg_prompt > label > textarea { font-size: 1.1rem; } #img2img_settings { min-width: calc(2 * var(--left-column)); max-width: calc(2 * var(--left-column)); background-color: #111111; padding-top: 16px; } #interrogate, #deepbooru { margin: 0 0px 10px 0px; max-width: 80px; max-height: 80px; font-weight: normal; font-size: 0.95em; } #lightboxModal { background-color: rgba(20, 20, 20, 0.8) } #quicksettings .gr-button-tool { font-size: 1.6rem; box-shadow: none; margin-left: -20px; margin-top: -2px; height: 2.4em; } #quicksettings > div, #quicksettings > fieldset { min-width: 24em; max-width: 26em; line-height: 2em; } -#refresh_sd_model_checkpoint { height: 48px; margin-left: -14px; background: #333333; box-shadow: none; } #open_folder_extras, #footer, #style_pos_col, #style_neg_col, #roll_col, #extras_upscaler_2, #extras_upscaler_2_visibility, #txt2img_seed_resize_from_w, #txt2img_seed_resize_from_h { display: none; } -#save-animation { border-radius: 0 !important; margin-bottom: 16px; background-color: #111111; } +#save-animation { border-radius: var(--radius-sm) !important; margin-bottom: 16px; background-color: #111111; } #script_list { padding: 4px; margin-top: 20px; margin-bottom: 20px; } #settings > div.flex-wrap { width: 15em; } #tab_extensions table { background-color: #222222; } @@ -222,15 +228,12 @@ svg.feather.feather-image, .feather .feather-image { display: none } --spacing-xxs: 1px; --spacing-xs: 2px; --spacing-sm: 4px; - --spacing-md: 6px; --spacing-lg: 8px; --spacing-xl: 10px; --spacing-xxl: 18px; --radius-xxs: 0; --radius-xs: 0; - --radius-sm: 0; --radius-md: 0; - --radius-lg: 0; --radius-xl: 0; --radius-xxl: 0; --text-xxs: 9px; diff --git a/modules/devices.py b/modules/devices.py index eedbb432b..96a49baa8 100644 --- a/modules/devices.py +++ b/modules/devices.py @@ -8,6 +8,8 @@ if sys.platform == "darwin": from modules import mac_specific # pylint: disable=ungrouped-imports cuda_ok = torch.cuda.is_available() +previous_oom = 0 + def has_mps() -> bool: if sys.platform != "darwin": @@ -15,6 +17,7 @@ def has_mps() -> bool: else: return mac_specific.has_mps + def extract_device_id(args, name): # pylint: disable=redefined-outer-name for x in range(len(args)): if name in args[x]: @@ -61,6 +64,18 @@ def get_device_for(task): def torch_gc(force=False): + mem = memstats.memory_stats() + gpu = mem.get('gpu', {}) + oom = gpu.get('oom', 0) + used = round(100 * gpu.get('used', 0) / gpu.get('total', 1)) + global previous_oom # pylint: disable=global-statement + if oom > previous_oom: + previous_oom = oom + shared.log.warning(f'GPU out-of-memory error: {mem}') + if used > 90: + shared.log.warning(f'GPU high memory utilization: {used}% {mem}') + force = True + if shared.opts.disable_gc and not force: return collected = gc.collect() diff --git a/modules/lora_diffusers.py b/modules/lora_diffusers.py index a6a5cbad7..e5779b3c6 100644 --- a/modules/lora_diffusers.py +++ b/modules/lora_diffusers.py @@ -1,7 +1,7 @@ import diffusers from modules import shared -lora_state = { # TODO this is ugly but diffusers +lora_state = { # TODO Lora state for Diffusers 'multiplier': 1.0, 'active': False, 'loaded': 0, diff --git a/modules/modelloader.py b/modules/modelloader.py index 6de1efe6a..a2d616b01 100644 --- a/modules/modelloader.py +++ b/modules/modelloader.py @@ -30,7 +30,10 @@ def download_diffusers_model(hub_id: str, cache_dir: str = None, download_config shared.log.debug(f"Diffusers authentication: {token}") hf.login(token) pipeline_dir = DiffusionPipeline.download(hub_id, **download_config) - model_info_dict = hf.model_info(hub_id).cardData # TODO hfhub card-data? + try: + model_info_dict = hf.model_info(hub_id).cardData # TODO HF-Hub cardData invalid property + except Exception: + model_info_dict = None # some checkpoints need to be downloaded as "hidden" as they just serve as pre- or post-pipelines of other pipelines if model_info_dict is not None and "prior" in model_info_dict: download_dir = DiffusionPipeline.download(model_info_dict["prior"], **download_config) diff --git a/modules/models/diffusion/ddpm_edit.py b/modules/models/diffusion/ddpm_edit.py index 72e011d6f..ad067dd8b 100644 --- a/modules/models/diffusion/ddpm_edit.py +++ b/modules/models/diffusion/ddpm_edit.py @@ -174,7 +174,6 @@ class DDPM(pl.LightningModule): lvlb_weights = 0.5 * np.sqrt(torch.Tensor(alphas_cumprod)) / (2. * 1 - torch.Tensor(alphas_cumprod)) else: raise NotImplementedError("mu not supported") - # TODO how to choose this term lvlb_weights[0] = lvlb_weights[1] self.register_buffer('lvlb_weights', lvlb_weights, persistent=False) assert not torch.isnan(self.lvlb_weights).all() @@ -887,7 +886,7 @@ class LatentDiffusion(DDPM): assert c is not None if self.cond_stage_trainable: c = self.get_learned_conditioning(c) - if self.shorten_cond_schedule: # TODO: drop this option + if self.shorten_cond_schedule: tc = self.cond_ids[t].to(self.device) c = self.q_sample(x_start=c, t=tc, noise=torch.randn_like(c.float())) return self.p_losses(x, c, t, *args, **kwargs) @@ -1430,7 +1429,6 @@ class DiffusionWrapper(pl.LightningModule): class Layout2ImgDiffusion(LatentDiffusion): - # TODO: move all layout-specific hacks to this class def __init__(self, cond_stage_key, *args, **kwargs): assert cond_stage_key == 'coordinates_bbox', 'Layout2ImgDiffusion only for cond_stage_key="coordinates_bbox"' super().__init__(*args, cond_stage_key=cond_stage_key, **kwargs) diff --git a/modules/processing.py b/modules/processing.py index 9ebd20ede..4854a1c31 100644 --- a/modules/processing.py +++ b/modules/processing.py @@ -447,24 +447,23 @@ def create_infotext(p: StableDiffusionProcessing, all_prompts, all_seeds, all_su if uses_ensd: uses_ensd = sd_samplers_common.is_sampler_using_eta_noise_seed_delta(p) - generation_params = { "Steps": p.steps, "Sampler": p.sampler_name, "CFG scale": p.cfg_scale, "Image CFG scale": getattr(p, 'image_cfg_scale', None), "Seed": all_seeds[index], - "Face restoration": (opts.face_restoration_model if p.restore_faces else None), + "Face restoration": opts.face_restoration_model if p.restore_faces else None, "Size": f"{p.width}x{p.height}", "Model hash": getattr(p, 'sd_model_hash', None if not opts.add_model_hash_to_info or not shared.sd_model.sd_model_hash else shared.sd_model.sd_model_hash), - "Model": (None if not opts.add_model_name_to_info or not shared.sd_model.sd_checkpoint_info.model_name else shared.sd_model.sd_checkpoint_info.model_name.replace(',', '').replace(':', '')), - "VAE": (None if not opts.add_model_name_to_info or sd_vae.loaded_vae_file is None else os.path.splitext(os.path.basename(sd_vae.loaded_vae_file))[0]), - "Variation seed": (None if p.subseed_strength == 0 else all_subseeds[index]), - "Variation seed strength": (None if p.subseed_strength == 0 else p.subseed_strength), - "Seed resize from": (None if p.seed_resize_from_w == 0 or p.seed_resize_from_h == 0 else f"{p.seed_resize_from_w}x{p.seed_resize_from_h}"), + "Model": None if not opts.add_model_name_to_info or not shared.sd_model.sd_checkpoint_info.model_name else shared.sd_model.sd_checkpoint_info.model_name.replace(',', '').replace(':', ''), + "VAE": None if not opts.add_model_name_to_info or sd_vae.loaded_vae_file is None else os.path.splitext(os.path.basename(sd_vae.loaded_vae_file))[0], + "Variation seed": None if p.subseed_strength == 0 else all_subseeds[index], + "Variation seed strength": None if p.subseed_strength == 0 else p.subseed_strength, + "Seed resize from": None if p.seed_resize_from_w == 0 or p.seed_resize_from_h == 0 else f"{p.seed_resize_from_w}x{p.seed_resize_from_h}", "Denoising strength": getattr(p, 'denoising_strength', None), "Conditional mask weight": getattr(p, "inpainting_mask_weight", shared.opts.inpainting_mask_weight) if p.is_using_inpainting_conditioning else None, - "Clip skip": p.clip_skip, + "Clip skip": p.clip_skip if p.clip_skip > 1 else None, "ENSD": opts.eta_noise_seed_delta if uses_ensd else None, "Init image hash": getattr(p, 'init_img_hash', None), "Version": git_commit, @@ -705,7 +704,9 @@ def process_images_inner(p: StableDiffusionProcessing) -> Processed: # TODO(PVP): change out to latents once possible with `diffusers` task_specific_kwargs = {"image": p.init_images[0], "mask_image": p.image_mask, "strength": p.denoising_strength} - def diffusers_callback(step: int, _timestep: int, latents: torch.FloatTensor): # TODO simplified callback for now + # TODO Diffusers limited callbacks + # TODO Diffusers processing is not using p.sample so second pass is ignored + def diffusers_callback(step: int, _timestep: int, latents: torch.FloatTensor): shared.state.sampling_step = step shared.state.sampling_steps = p.steps shared.state.current_latent = latents @@ -728,9 +729,9 @@ def process_images_inner(p: StableDiffusionProcessing) -> Processed: # shared.sd_model.to('cpu') # devices.torch_gc(force=True) - if shared.sd_refiner is not None: # shared.sd_refiner.to(devices.device) + devices.torch_gc() init_image = output.images[0] output = shared.sd_refiner( # pylint: disable=not-callable prompt=prompts, diff --git a/modules/sd_models.py b/modules/sd_models.py index 505d79101..dcc7d4df5 100644 --- a/modules/sd_models.py +++ b/modules/sd_models.py @@ -59,6 +59,8 @@ class CheckpointInfo: self.sha256 = hashes.sha256_from_cache(self.filename, f"checkpoint/{name}") self.path = abspath self.type = abspath.split('.')[-1].lower() + self.name_for_extra = os.path.splitext(os.path.basename(filename))[0] + self.model_name = os.path.splitext(name.replace("/", "_").replace("\\", "_"))[0] else: # maybe a diffuser repo = [r for r in modelloader.diffuser_repos if filename == r['filename']] if len(repo) == 0: @@ -70,16 +72,13 @@ class CheckpointInfo: self.sha256 = repo[0]['hash'] self.path = repo[0]['path'] self.type = 'diffusers' - + self.name_for_extra = repo[0]['name'] + self.model_name = repo[0]['name'] if os.path.isfile(repo[0]['model_info']): file_path = repo[0]['model_info'] with open(file_path, "r", encoding="utf-8") as json_file: self.model_info = json.load(json_file) - else: - self.model_info = None - self.name_for_extra = os.path.splitext(os.path.basename(filename))[0] - self.model_name = os.path.splitext(name.replace("/", "_").replace("\\", "_"))[0] self.shorthash = self.sha256[0:10] if self.sha256 else None self.title = self.name if self.shorthash is None else f'{self.name} [{self.shorthash}]' self.ids = [self.hash, self.model_name, self.title, self.name, f'{self.name} [{self.hash}]'] + ([self.shorthash, self.sha256, f'{self.name} [{self.shorthash}]'] if self.shorthash else []) @@ -161,7 +160,7 @@ def list_models(): model_list = modelloader.load_models(model_path=model_path, model_url=model_url, command_path=shared.opts.ckpt_dir, ext_filter=[".ckpt", ".safetensors"], download_name="v1-5-pruned-emaonly.safetensors", ext_blacklist=[".vae.ckpt", ".vae.safetensors"]) else: default_model_id = "runwayml/stable-diffusion-v1-5" - modelloader.download_diffusers_model(default_model_id, os.path.join(models_path, 'Diffusers')) + modelloader.download_diffusers_model(default_model_id, shared.opts.diffusers_dir) model_list = modelloader.load_diffusers_models(model_path=os.path.join(models_path, 'Diffusers'), command_path=shared.opts.diffusers_dir) for filename in sorted(model_list, key=str.lower): @@ -665,6 +664,8 @@ def load_diffuser(checkpoint_info=None, already_loaded_state_dict=None, timer=No else: shared.log.error(f'Diffusers cannot load safetensor model: {checkpoint_info.path} {shared.opts.diffusers_pipeline}') return + if sd_model is not None: + shared.log.debug(f'Diffusers pipeline: {type(sd_model)}') # pylint: disable=protected-access except Exception as e: shared.log.error(f'Diffusers failed loading model using pipeline: {checkpoint_info.path} {shared.opts.diffusers_pipeline} {e}') return diff --git a/modules/ui_models.py b/modules/ui_models.py index 325eca125..592fb2e08 100644 --- a/modules/ui_models.py +++ b/modules/ui_models.py @@ -219,5 +219,5 @@ def create_ui(): hf_results.select(fn=hf_select, inputs=[hf_results], outputs=[hf_selected]) hf_download_model_btn.click(fn=hf_download_model, inputs=[hf_selected, hf_token], outputs=[models_outcome]) - with gr.Tab(label="CivitAI"): - pass + # with gr.Tab(label="CivitAI"): + # pass diff --git a/requirements.txt b/requirements.txt index dd1ada5a4..7f93f9fa3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -59,7 +59,7 @@ pandas==1.5.3 protobuf==3.20.3 pytorch_lightning==1.9.4 transformers==4.30.2 -timm==0.6.13 tomesd==0.1.3 urllib3==1.26.15 Pillow==9.5.0 +timm==0.6.13 diff --git a/wiki b/wiki index 503fa982c..e5da783be 160000 --- a/wiki +++ b/wiki @@ -1 +1 @@ -Subproject commit 503fa982cba22a33f3601f43fd255883167e421c +Subproject commit e5da783bef6cacdb669c482d726e23a8d20d86d8