diff --git a/modules/ui/AdditionalEmbeddingsTab.py b/modules/ui/AdditionalEmbeddingsTab.py index d6cef6ca8..6a5e3fbe7 100644 --- a/modules/ui/AdditionalEmbeddingsTab.py +++ b/modules/ui/AdditionalEmbeddingsTab.py @@ -1,4 +1,3 @@ -from pathlib import Path from modules.ui.ConfigList import ConfigList from modules.util.config.TrainConfig import TrainConfig, TrainEmbeddingConfig @@ -89,9 +88,9 @@ def __init__(self, master, element, i, open_command, remove_command, clone_comma # embedding model names components.label(top_frame, 0, 2, "base embedding:", tooltip="The base embedding to train on. Leave empty to create a new embedding") - components.file_entry( + components.path_entry( top_frame, 0, 3, self.ui_state, "model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # placeholder diff --git a/modules/ui/CloudTab.py b/modules/ui/CloudTab.py index 33ea64f99..99057e428 100644 --- a/modules/ui/CloudTab.py +++ b/modules/ui/CloudTab.py @@ -66,7 +66,7 @@ def __init__(self, master, train_config: TrainConfig, ui_state: UIState, parent) components.label(self.frame, 7, 0, "SSH keyfile path", tooltip="Absolute path to the private key file used for SSH connections. Leave empty to rely on your system SSH configuration.") - components.file_entry(self.frame, 7, 1, self.ui_state, "secrets.cloud.key_file") #TODO Replace with path_entry in a future PR + components.path_entry(self.frame, 7, 1, self.ui_state, "secrets.cloud.key_file", mode="file") components.label(self.frame, 8, 0, "SSH password", tooltip="SSH password for password-based authentication. If you try to use native SCP requires sshpass to be installed. Leave empty to use key-based authentication.") diff --git a/modules/ui/ConceptTab.py b/modules/ui/ConceptTab.py index a3806c15d..f96134682 100644 --- a/modules/ui/ConceptTab.py +++ b/modules/ui/ConceptTab.py @@ -10,8 +10,8 @@ from modules.util.enum.ConceptType import ConceptType from modules.util.image_util import load_image from modules.util.ui import components -from modules.util.ui.ui_utils import DebounceTimer from modules.util.ui.UIState import UIState +from modules.util.ui.validation import DebounceTimer import customtkinter as ctk from PIL import Image diff --git a/modules/ui/ConceptWindow.py b/modules/ui/ConceptWindow.py index ab7374dd2..088080704 100644 --- a/modules/ui/ConceptWindow.py +++ b/modules/ui/ConceptWindow.py @@ -147,14 +147,14 @@ def __general_tab(self, master, concept: ConceptConfig): # path components.label(frame, 3, 0, "Path", tooltip="Path where the training data is located") - components.dir_entry(frame, 3, 1, self.ui_state, "path") + components.path_entry(frame, 3, 1, self.ui_state, "path", mode="dir") components.button(frame, 3, 2, text="download now", command=self.__download_dataset_threaded, tooltip="Download dataset from Huggingface now, for the purpose of previewing and statistics. Otherwise, it will be downloaded when you start training. Path must be a Huggingface repository.") # prompt source components.label(frame, 4, 0, "Prompt Source", tooltip="The source for prompts used during training. When selecting \"From single text file\", select a text file that contains a list of prompts") - prompt_path_entry = components.file_entry(frame, 4, 2, self.text_ui_state, "prompt_path") + prompt_path_entry = components.path_entry(frame, 4, 2, self.text_ui_state, "prompt_path", mode="file") def set_prompt_path_entry_enabled(option: str): if option == 'concept': diff --git a/modules/ui/ConvertModelUI.py b/modules/ui/ConvertModelUI.py index db4de37e1..6cb1b507a 100644 --- a/modules/ui/ConvertModelUI.py +++ b/modules/ui/ConvertModelUI.py @@ -1,5 +1,4 @@ import traceback -from pathlib import Path from uuid import uuid4 from modules.util import create @@ -8,6 +7,7 @@ from modules.util.enum.DataType import DataType from modules.util.enum.ModelFormat import ModelFormat from modules.util.enum.ModelType import ModelType +from modules.util.enum.PathIOType import PathIOType from modules.util.enum.TrainingMethod import TrainingMethod from modules.util.ModelNames import EmbeddingName, ModelNames from modules.util.torch_util import torch_gc @@ -85,9 +85,9 @@ def main_frame(self, master): # input name components.label(master, 2, 0, "Input name", tooltip="Filename, directory or hugging face repository of the base model") - components.file_entry( + components.path_entry( master, 2, 1, self.ui_state, "input_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # output data type @@ -110,7 +110,11 @@ def main_frame(self, master): # output model destination components.label(master, 5, 0, "Model Output Destination", tooltip="Filename or directory where the output model is saved") - components.file_entry(master, 5, 1, self.ui_state, "output_model_destination", is_output=True) + components.path_entry( + master, 5, 1, self.ui_state, "output_model_destination", + mode="file", + io_type=PathIOType.MODEL, + ) self.button = components.button(master, 6, 1, "Convert", self.convert_model) diff --git a/modules/ui/LoraTab.py b/modules/ui/LoraTab.py index 902f2c8c2..1c73d90ce 100644 --- a/modules/ui/LoraTab.py +++ b/modules/ui/LoraTab.py @@ -1,10 +1,10 @@ -from pathlib import Path from modules.util.config.TrainConfig import TrainConfig from modules.util.enum.DataType import DataType from modules.util.enum.ModelType import PeftType from modules.util.ui import components from modules.util.ui.UIState import UIState +from modules.util.ui.validation_helpers import check_range import customtkinter as ctk @@ -64,9 +64,9 @@ def setup_lora(self, peft_type: PeftType): # lora model name components.label(master, 0, 0, f"{name} base model", tooltip=f"The base {name} to train on. Leave empty to create a new {name}") - entry = components.file_entry( + entry = components.path_entry( master, 0, 1, self.ui_state, "lora_model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) entry.grid(row=0, column=1, columnspan=4) @@ -89,12 +89,12 @@ def setup_lora(self, peft_type: PeftType): # rank components.label(master, 1, 0, f"{name} rank", tooltip=f"The rank parameter used when creating a new {name}") - components.entry(master, 1, 1, self.ui_state, "lora_rank") + components.entry(master, 1, 1, self.ui_state, "lora_rank", required=True, extra_validate=check_range(lower=1, message="Rank must be at least 1")) # alpha components.label(master, 2, 0, f"{name} alpha", tooltip=f"The alpha parameter used when creating a new {name}") - components.entry(master, 2, 1, self.ui_state, "lora_alpha") + components.entry(master, 2, 1, self.ui_state, "lora_alpha", required=True) # Dropout Percentage components.label(master, 3, 0, "Dropout Probability", @@ -119,7 +119,7 @@ def setup_lora(self, peft_type: PeftType): # Block Size components.label(master, 1, 0, f"{name} Block Size", tooltip=f"The block size parameter used when creating a new {name}") - components.entry(master, 1, 1, self.ui_state, "oft_block_size") + components.entry(master, 1, 1, self.ui_state, "oft_block_size", required=True) # COFT components.label(master, 1, 3, "Constrained OFT (COFT)", diff --git a/modules/ui/ModelTab.py b/modules/ui/ModelTab.py index b8ad3d26d..6ff98d086 100644 --- a/modules/ui/ModelTab.py +++ b/modules/ui/ModelTab.py @@ -1,10 +1,10 @@ -from pathlib import Path from modules.util import create from modules.util.config.TrainConfig import TrainConfig from modules.util.enum.ConfigPart import ConfigPart from modules.util.enum.DataType import DataType from modules.util.enum.ModelFormat import ModelFormat +from modules.util.enum.PathIOType import PathIOType from modules.util.enum.TrainingMethod import TrainingMethod from modules.util.ui import components from modules.util.ui.UIState import UIState @@ -365,9 +365,9 @@ def __create_base_dtype_components(self, frame, row: int) -> int: # base model components.label(frame, row, 0, "Base Model", tooltip="Filename, directory or Hugging Face repository of the base model") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "base_model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # compile @@ -410,9 +410,9 @@ def __create_base_components( # prior model components.label(frame, row, 0, "Prior Model", tooltip="Filename, directory or Hugging Face repository of the prior model") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "prior.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # prior weight dtype @@ -428,9 +428,9 @@ def __create_base_components( # transformer model components.label(frame, row, 0, "Override Transformer / GGUF", tooltip="Can be used to override the transformer in the base model. Safetensors and GGUF files are supported, local and on Huggingface. If a GGUF file is used, the DataType must also be set to GGUF") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "transformer.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # transformer weight dtype @@ -512,9 +512,9 @@ def __create_base_components( # text encoder 4 weight dtype components.label(frame, row, 0, "Text Encoder 4 Override", tooltip="Filename, directory or Hugging Face repository of the text encoder 4 model") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "text_encoder_4.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # text encoder 4 weight dtype @@ -529,9 +529,9 @@ def __create_base_components( # base model components.label(frame, row, 0, "VAE Override", tooltip="Directory or Hugging Face repository of a VAE model in diffusers format. Can be used to override the VAE included in the base model. Using a safetensor VAE file will cause an error that the model cannot be loaded.") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "vae.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # vae weight dtype @@ -548,9 +548,9 @@ def __create_effnet_encoder_components(self, frame, row: int): # effnet encoder model components.label(frame, row, 0, "Effnet Encoder Model", tooltip="Filename, directory or Hugging Face repository of the effnet encoder model") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "effnet_encoder.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # effnet encoder weight dtype @@ -572,9 +572,9 @@ def __create_decoder_components( # decoder model components.label(frame, row, 0, "Decoder Model", tooltip="Filename, directory or Hugging Face repository of the decoder model") - components.file_entry( + components.path_entry( frame, row, 1, self.ui_state, "decoder.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # decoder weight dtype @@ -616,7 +616,11 @@ def __create_output_components( # output model destination components.label(frame, row, 0, "Model Output Destination", tooltip="Filename or directory where the output model is saved") - components.file_entry(frame, row, 1, self.ui_state, "output_model_destination", is_output=True) + components.path_entry( + frame, row, 1, self.ui_state, "output_model_destination", + mode="file", + io_type=PathIOType.MODEL, + ) # output data type components.label(frame, row, 3, "Output Data Type", diff --git a/modules/ui/SampleFrame.py b/modules/ui/SampleFrame.py index 2f35a23a2..1b46aaa18 100644 --- a/modules/ui/SampleFrame.py +++ b/modules/ui/SampleFrame.py @@ -108,15 +108,15 @@ def __init__( # base image path components.label(bottom_frame, 6, 0, "base image path:", tooltip="The base image used when inpainting.") - components.file_entry(bottom_frame, 6, 1, self.ui_state, "base_image_path", - allow_model_files=False, + components.path_entry(bottom_frame, 6, 1, self.ui_state, "base_image_path", + mode="file", allow_model_files=False, allow_image_files=True, ) # mask image path components.label(bottom_frame, 6, 2, "mask image path:", tooltip="The mask used when inpainting.") - components.file_entry(bottom_frame, 6, 3, self.ui_state, "mask_image_path", - allow_model_files=False, + components.path_entry(bottom_frame, 6, 3, self.ui_state, "mask_image_path", + mode="file", allow_model_files=False, allow_image_files=True, ) diff --git a/modules/ui/TrainUI.py b/modules/ui/TrainUI.py index 584b68f81..2b6cc6383 100644 --- a/modules/ui/TrainUI.py +++ b/modules/ui/TrainUI.py @@ -12,7 +12,7 @@ from collections.abc import Callable from contextlib import suppress from pathlib import Path -from tkinter import filedialog +from tkinter import filedialog, messagebox import scripts.generate_debug_report from modules.ui.AdditionalEmbeddingsTab import AdditionalEmbeddingsTab @@ -36,12 +36,14 @@ from modules.util.enum.GradientReducePrecision import GradientReducePrecision from modules.util.enum.ImageFormat import ImageFormat from modules.util.enum.ModelType import ModelType +from modules.util.enum.PathIOType import PathIOType from modules.util.enum.TrainingMethod import TrainingMethod from modules.util.torch_util import torch_gc from modules.util.TrainProgress import TrainProgress from modules.util.ui import components from modules.util.ui.ui_utils import set_window_icon from modules.util.ui.UIState import UIState +from modules.util.ui.validation import flush_and_validate_all import torch @@ -239,12 +241,12 @@ def create_general_tab(self, master): # workspace dir components.label(frame, 0, 0, "Workspace Directory", tooltip="The directory where all files of this training run are saved") - components.dir_entry(frame, 0, 1, self.ui_state, "workspace_dir", command=self._on_workspace_dir_change) + components.path_entry(frame, 0, 1, self.ui_state, "workspace_dir", mode="dir", command=self._on_workspace_dir_change) # cache dir components.label(frame, 0, 2, "Cache Directory", tooltip="The directory where cached data is saved") - components.dir_entry(frame, 0, 3, self.ui_state, "cache_dir") + components.path_entry(frame, 0, 3, self.ui_state, "cache_dir", mode="dir") # continue from previous backup components.label(frame, 2, 0, "Continue from last backup", @@ -256,6 +258,12 @@ def create_general_tab(self, master): tooltip="Only populate the cache, without any training") components.switch(frame, 2, 3, self.ui_state, "only_cache") + # TODO: In Phase 4 rework the general tab. + # prevent overwrites + components.label(frame, 3, 0, "Prevent Overwrites", + tooltip="When enabled, output paths that already exist on disk will be flagged as invalid to avoid accidental overwrites") + components.switch(frame, 3, 1, self.ui_state, "prevent_overwrites") + # debug components.label(frame, 4, 0, "Debug mode", tooltip="Save debug information during the training into the debug directory") @@ -263,7 +271,7 @@ def create_general_tab(self, master): components.label(frame, 4, 2, "Debug Directory", tooltip="The directory where debug data is saved") - components.dir_entry(frame, 4, 3, self.ui_state, "debug_dir") + components.path_entry(frame, 4, 3, self.ui_state, "debug_dir", mode="dir", io_type=PathIOType.OUTPUT) # tensorboard components.label(frame, 6, 0, "Tensorboard", @@ -294,11 +302,11 @@ def create_general_tab(self, master): # device components.label(frame, 10, 0, "Dataloader Threads", tooltip="Number of threads used for the data loader. Increase if your GPU has room during caching, decrease if it's going out of memory during caching.") - components.entry(frame, 10, 1, self.ui_state, "dataloader_threads") + components.entry(frame, 10, 1, self.ui_state, "dataloader_threads", required=True) components.label(frame, 11, 0, "Train Device", tooltip="The device used for training. Can be \"cuda\", \"cuda:0\", \"cuda:1\" etc. Default:\"cuda\". Must be \"cuda\" for multi-GPU training.") - components.entry(frame, 11, 1, self.ui_state, "train_device") + components.entry(frame, 11, 1, self.ui_state, "train_device", required=True) components.label(frame, 12, 0, "Multi-GPU", tooltip="Enable multi-GPU training") @@ -468,53 +476,6 @@ def create_backup_tab(self, master): frame.pack(fill="both", expand=1) return frame - def lora_tab(self, master): - frame = ctk.CTkScrollableFrame(master, fg_color="transparent") - frame.grid_columnconfigure(0, weight=0) - frame.grid_columnconfigure(1, weight=1) - frame.grid_columnconfigure(2, minsize=50) - frame.grid_columnconfigure(3, weight=0) - frame.grid_columnconfigure(4, weight=1) - - # lora model name - components.label(frame, 0, 0, "LoRA base model", - tooltip="The base LoRA to train on. Leave empty to create a new LoRA") - components.file_entry( - frame, 0, 1, self.ui_state, "lora_model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x - ) - - # lora rank - components.label(frame, 1, 0, "LoRA rank", - tooltip="The rank parameter used when creating a new LoRA") - components.entry(frame, 1, 1, self.ui_state, "lora_rank") - - # lora rank - components.label(frame, 2, 0, "LoRA alpha", - tooltip="The alpha parameter used when creating a new LoRA") - components.entry(frame, 2, 1, self.ui_state, "lora_alpha") - - # Dropout Percentage - components.label(frame, 3, 0, "Dropout Probability", - tooltip="Dropout probability. This percentage of model nodes will be randomly ignored at each training step. Helps with overfitting. 0 disables, 1 maximum.") - components.entry(frame, 3, 1, self.ui_state, "dropout_probability") - - # lora weight dtype - components.label(frame, 4, 0, "LoRA Weight Data Type", - tooltip="The LoRA weight data type used for training. This can reduce memory consumption, but reduces precision") - components.options_kv(frame, 4, 1, [ - ("float32", DataType.FLOAT_32), - ("bfloat16", DataType.BFLOAT_16), - ], self.ui_state, "lora_weight_dtype") - - # For use with additional embeddings. - components.label(frame, 5, 0, "Bundle Embeddings", - tooltip="Bundles any additional embeddings into the LoRA output file, rather than as separate files") - components.switch(frame, 5, 1, self.ui_state, "bundle_additional_embeddings") - - frame.pack(fill="both", expand=1) - return frame - def embedding_tab(self, master): frame = ctk.CTkScrollableFrame(master, fg_color="transparent") frame.grid_columnconfigure(0, weight=0) @@ -526,9 +487,9 @@ def embedding_tab(self, master): # embedding model name components.label(frame, 0, 0, "Base embedding", tooltip="The base embedding to train on. Leave empty to create a new embedding") - components.file_entry( + components.path_entry( frame, 0, 1, self.ui_state, "embedding.model_name", - path_modifier=lambda x: Path(x).parent.absolute() if x.endswith(".json") else x + mode="file", path_modifier=components.json_path_modifier ) # token count @@ -784,6 +745,18 @@ def __training_thread_function(self): def start_training(self): if self.training_thread is None: self.save_default() + + # --- pre-training validation gate --- + errors = flush_and_validate_all() + + if errors: + bullet_list = "\n".join(f"• {e}" for e in errors) + messagebox.showerror( + "Cannot Start Training", + f"Please fix the following errors before training:\n\n{bullet_list}", + ) + return + self._set_training_button_running() if self.train_config.tensorboard and not self.train_config.tensorboard_always_on and self.always_on_tensorboard_subprocess: diff --git a/modules/ui/TrainingTab.py b/modules/ui/TrainingTab.py index eec2a51e4..7928b6832 100644 --- a/modules/ui/TrainingTab.py +++ b/modules/ui/TrainingTab.py @@ -16,6 +16,7 @@ from modules.util.optimizer_util import change_optimizer from modules.util.ui import components from modules.util.ui.UIState import UIState +from modules.util.ui.validation_helpers import check_range, validate_resolution import customtkinter as ctk @@ -290,7 +291,7 @@ def __create_base_frame(self, master, row): # learning rate components.label(frame, 2, 0, "Learning Rate", tooltip="The base learning rate") - components.entry(frame, 2, 1, self.ui_state, "learning_rate") + components.entry(frame, 2, 1, self.ui_state, "learning_rate", required=True) # learning rate warmup steps components.label(frame, 3, 0, "Learning Rate Warmup Steps", @@ -300,7 +301,8 @@ def __create_base_frame(self, master, row): # learning rate min factor components.label(frame, 4, 0, "Learning Rate Min Factor", tooltip="Unit = float. Method = percentage. For a factor of 0.1, the final LR will be 10% of the initial LR. If the initial LR is 1e-4, the final LR will be 1e-5.") - components.entry(frame, 4, 1, self.ui_state, "learning_rate_min_factor") + components.entry(frame, 4, 1, self.ui_state, "learning_rate_min_factor", + extra_validate=check_range(lower=0, upper=0.99, message="Learning rate min factor must be between 0 and 0.99")) # learning rate cycles components.label(frame, 5, 0, "Learning Rate Cycles", @@ -310,17 +312,17 @@ def __create_base_frame(self, master, row): # epochs components.label(frame, 6, 0, "Epochs", tooltip="The number of epochs for a full training run") - components.entry(frame, 6, 1, self.ui_state, "epochs") + components.entry(frame, 6, 1, self.ui_state, "epochs", required=True) # batch size components.label(frame, 7, 0, "Local Batch Size", tooltip="The batch size of one training step. If you use multiple GPUs, this is the batch size of each GPU (local batch size).") - components.entry(frame, 7, 1, self.ui_state, "batch_size") + components.entry(frame, 7, 1, self.ui_state, "batch_size", required=True) # accumulation steps components.label(frame, 8, 0, "Accumulation Steps", tooltip="Number of accumulation steps. Increase this number to trade batch size for training speed") - components.entry(frame, 8, 1, self.ui_state, "gradient_accumulation_steps") + components.entry(frame, 8, 1, self.ui_state, "gradient_accumulation_steps", required=True) # Learning Rate Scaler components.label(frame, 9, 0, "Learning Rate Scaler", @@ -348,7 +350,9 @@ def __create_base2_frame(self, master, row, video_training_enabled: bool = False # ema decay components.label(frame, row, 0, "EMA Decay", tooltip="Decay parameter of the EMA model. Higher numbers will average more steps. For datasets of hundreds or thousands of images, set this to 0.9999. For smaller datasets, set it to 0.999 or even 0.998") - components.entry(frame, row, 1, self.ui_state, "ema_decay") + components.entry(frame, row, 1, self.ui_state, "ema_decay", + extra_validate=check_range(lower=0.5, upper=1, + message="EMA decay must be between 0.5 and 1")) row += 1 # ema update step interval @@ -399,14 +403,15 @@ def __create_base2_frame(self, master, row, video_training_enabled: bool = False # resolution components.label(frame, row, 0, "Resolution", tooltip="The resolution used for training. Optionally specify multiple resolutions separated by a comma, or a single exact resolution in the format x") - components.entry(frame, row, 1, self.ui_state, "resolution") + components.entry(frame, row, 1, self.ui_state, "resolution", required=True, + extra_validate=validate_resolution()) row += 1 # frames if video_training_enabled: components.label(frame, row, 0, "Frames", tooltip="The number of frames used for training.") - components.entry(frame, row, 1, self.ui_state, "frames") + components.entry(frame, row, 1, self.ui_state, "frames", required=True) row += 1 # force circular padding @@ -649,27 +654,27 @@ def __create_noise_frame(self, master, row, supports_generalized_offset_noise: b # min noising strength components.label(frame, 4, 0, "Min Noising Strength", tooltip="Specifies the minimum noising strength used during training. This can help to improve composition, but prevents finer details from being trained") - components.entry(frame, 4, 1, self.ui_state, "min_noising_strength") + components.entry(frame, 4, 1, self.ui_state, "min_noising_strength", required=True) # max noising strength components.label(frame, 5, 0, "Max Noising Strength", tooltip="Specifies the maximum noising strength used during training. This can be useful to reduce overfitting, but also reduces the impact of training samples on the overall image composition") - components.entry(frame, 5, 1, self.ui_state, "max_noising_strength") + components.entry(frame, 5, 1, self.ui_state, "max_noising_strength", required=True) # noising weight components.label(frame, 6, 0, "Noising Weight", tooltip="Controls the weight parameter of the timestep distribution function. Use the preview to see more details.") - components.entry(frame, 6, 1, self.ui_state, "noising_weight") + components.entry(frame, 6, 1, self.ui_state, "noising_weight", required=True) # noising bias components.label(frame, 7, 0, "Noising Bias", tooltip="Controls the bias parameter of the timestep distribution function. Use the preview to see more details.") - components.entry(frame, 7, 1, self.ui_state, "noising_bias") + components.entry(frame, 7, 1, self.ui_state, "noising_bias", required=True) # timestep shift components.label(frame, 8, 0, "Timestep Shift", tooltip="Shift the timestep distribution. Use the preview to see more details.") - components.entry(frame, 8, 1, self.ui_state, "timestep_shift") + components.entry(frame, 8, 1, self.ui_state, "timestep_shift", required=True) if supports_dynamic_timestep_shifting: # dynamic timestep shifting @@ -692,12 +697,14 @@ def __create_masked_frame(self, master, row): # unmasked probability components.label(frame, 1, 0, "Unmasked Probability", tooltip="When masked training is enabled, specifies the number of training steps done on unmasked samples") - components.entry(frame, 1, 1, self.ui_state, "unmasked_probability") + components.entry(frame, 1, 1, self.ui_state, "unmasked_probability", + extra_validate=check_range(lower=0, upper=1, message="Unmasked probability must be between 0 and 1")) # unmasked weight components.label(frame, 2, 0, "Unmasked Weight", tooltip="When masked training is enabled, specifies the loss weight of areas outside the masked region") - components.entry(frame, 2, 1, self.ui_state, "unmasked_weight") + components.entry(frame, 2, 1, self.ui_state, "unmasked_weight", + extra_validate=check_range(lower=0, upper=1, message="Unmasked weight must be between 0 and 1")) # normalize masked area loss components.label(frame, 3, 0, "Normalize Masked Area Loss", @@ -707,7 +714,8 @@ def __create_masked_frame(self, master, row): # masked prior preservation components.label(frame, 4, 0, "Masked Prior Preservation Weight", tooltip="Preserves regions outside the mask using the original untrained model output as a target. Only available for LoRA training. If enabled, use a low unmasked weight.") - components.entry(frame, 4, 1, self.ui_state, "masked_prior_preservation_weight") + components.entry(frame, 4, 1, self.ui_state, "masked_prior_preservation_weight", + extra_validate=check_range(lower=0, upper=1, message="Masked prior preservation weight must be between 0 and 1")) # use custom conditioning image components.label(frame, 5, 0, "Custom Conditioning Image", @@ -722,33 +730,33 @@ def __create_loss_frame(self, master, row, supports_vb_loss: bool = False): # MSE Strength components.label(frame, 0, 0, "MSE Strength", tooltip="Mean Squared Error strength for custom loss settings. Strengths should generally sum to 1.") - components.entry(frame, 0, 1, self.ui_state, "mse_strength") + components.entry(frame, 0, 1, self.ui_state, "mse_strength", required=True) # MAE Strength components.label(frame, 1, 0, "MAE Strength", tooltip="Mean Absolute Error strength for custom loss settings. Strengths should generally sum to 1.") - components.entry(frame, 1, 1, self.ui_state, "mae_strength") + components.entry(frame, 1, 1, self.ui_state, "mae_strength", required=True) # log-cosh Strength components.label(frame, 2, 0, "log-cosh Strength", tooltip="Log - Hyperbolic cosine Error strength for custom loss settings. Strengths should generally sum to 1.") - components.entry(frame, 2, 1, self.ui_state, "log_cosh_strength") + components.entry(frame, 2, 1, self.ui_state, "log_cosh_strength", required=True) # Huber Strength components.label(frame, 3, 0, "Huber Strength", tooltip="Huber loss strength for custom loss settings. Less sensitive to outliers than MSE. Strengths should generally sum to 1.") - components.entry(frame, 3, 1, self.ui_state, "huber_strength") + components.entry(frame, 3, 1, self.ui_state, "huber_strength", required=True) # Huber Delta components.label(frame, 4, 0, "Huber Delta", tooltip="Delta parameter for huber loss") - components.entry(frame, 4, 1, self.ui_state, "huber_delta") + components.entry(frame, 4, 1, self.ui_state, "huber_delta", required=True) if supports_vb_loss: # VB Strength components.label(frame, 5, 0, "VB Strength", tooltip="Variational lower-bound strength for custom loss settings. Should be set to 1 for variational diffusion models") - components.entry(frame, 5, 1, self.ui_state, "vb_loss_strength") + components.entry(frame, 5, 1, self.ui_state, "vb_loss_strength", required=True) # Loss Weight function components.label(frame, 6, 0, "Loss Weight Function", @@ -765,7 +773,8 @@ def __create_loss_frame(self, master, row, supports_vb_loss: bool = False): if not self.train_config.model_type.is_flow_matching(): components.label(frame, row, 0, "Gamma", tooltip="Inverse strength of loss weighting. Range: 1-20, only applies to Min SNR and P2.") - components.entry(frame, row, 1, self.ui_state, "loss_weight_strength") + components.entry(frame, row, 1, self.ui_state, "loss_weight_strength", + extra_validate=check_range(lower=1, upper=20, message="Gamma must be between 1 and 20")) row += 1 # Loss Scaler diff --git a/modules/util/config/TrainConfig.py b/modules/util/config/TrainConfig.py index 088a9308f..306a5cfb2 100644 --- a/modules/util/config/TrainConfig.py +++ b/modules/util/config/TrainConfig.py @@ -368,6 +368,7 @@ class TrainConfig(BaseConfig): validate_after: float validate_after_unit: TimeUnit continue_last_backup: bool + prevent_overwrites: bool include_train_config: ConfigPart # multi-GPU @@ -953,6 +954,7 @@ def default_values() -> 'TrainConfig': data.append(("validate_after", 1, int, False)) data.append(("validate_after_unit", TimeUnit.EPOCH, TimeUnit, False)) data.append(("continue_last_backup", False, bool, False)) + data.append(("prevent_overwrites", False, bool, False)) data.append(("include_train_config", ConfigPart.NONE, ConfigPart, False)) #multi-GPU diff --git a/modules/util/enum/PathIOType.py b/modules/util/enum/PathIOType.py new file mode 100644 index 000000000..6aae2fcb0 --- /dev/null +++ b/modules/util/enum/PathIOType.py @@ -0,0 +1,10 @@ +from enum import Enum + + +class PathIOType(Enum): + INPUT = "INPUT" + OUTPUT = "OUTPUT" + MODEL = "MODEL" + + def __str__(self): + return self.value diff --git a/modules/util/ui/UIState.py b/modules/util/ui/UIState.py index 0c858094b..8b13d23f7 100644 --- a/modules/util/ui/UIState.py +++ b/modules/util/ui/UIState.py @@ -1,4 +1,3 @@ -import contextlib import tkinter as tk from collections.abc import Callable from dataclasses import dataclass @@ -119,8 +118,10 @@ def update(_0, _1, _2): elif string_var == "-inf": obj[name] = int("-inf") else: - with contextlib.suppress(ValueError): + try: obj[name] = int(string_var) + except ValueError: + obj[name] = None self.__call_var_traces(name) else: def update(_0, _1, _2): @@ -132,8 +133,10 @@ def update(_0, _1, _2): elif string_var == "-inf": setattr(obj, name, int("-inf")) else: - with contextlib.suppress(ValueError): + try: setattr(obj, name, int(string_var)) + except ValueError: + setattr(obj, name, None) self.__call_var_traces(name) return update @@ -149,8 +152,10 @@ def update(_0, _1, _2): elif string_var == "-inf": obj[name] = float("-inf") else: - with contextlib.suppress(ValueError): + try: obj[name] = float(string_var) + except ValueError: + obj[name] = None self.__call_var_traces(name) else: def update(_0, _1, _2): @@ -162,8 +167,10 @@ def update(_0, _1, _2): elif string_var == "-inf": setattr(obj, name, float("-inf")) else: - with contextlib.suppress(ValueError): + try: setattr(obj, name, float(string_var)) + except ValueError: + setattr(obj, name, None) self.__call_var_traces(name) return update diff --git a/modules/util/ui/components.py b/modules/util/ui/components.py index 961d213a0..20e8c9390 100644 --- a/modules/util/ui/components.py +++ b/modules/util/ui/components.py @@ -1,12 +1,16 @@ import contextlib +import tkinter as tk from collections.abc import Callable +from pathlib import Path from tkinter import filedialog -from typing import Any +from typing import Any, Literal +from modules.util.enum.PathIOType import PathIOType from modules.util.enum.TimeUnit import TimeUnit from modules.util.path_util import supported_image_extensions from modules.util.ui.ToolTip import ToolTip from modules.util.ui.UIState import UIState +from modules.util.ui.validation import DEFAULT_MAX_UNDO, FieldValidator, PathValidator import customtkinter as ctk from customtkinter.windows.widgets.scaling import CTkScalingBaseClass @@ -44,11 +48,15 @@ def entry( column, ui_state: UIState, var_name: str, - command: Callable[[], None] = None, + command: Callable[[], None] | None = None, tooltip: str = "", wide_tooltip: bool = False, width: int = 140, sticky: str = "new", + max_undo: int | None = None, + validator_factory: Callable[..., FieldValidator] | None = None, + extra_validate: Callable[[str], str | None] | None = None, + required: bool = False, ): var = ui_state.get_var(var_name) trace_id = None @@ -58,140 +66,41 @@ def entry( component = ctk.CTkEntry(master, textvariable=var, width=width) component.grid(row=row, column=column, padx=PAD, pady=PAD, sticky=sticky) - try: - original_border_color = component.cget("border_color") - except Exception: - original_border_color = "gray50" - - error_border_color = "#dc3545" - - validation_after_id = None - revert_after_id = None - touched = False - - DEBOUNCE_STOP_TYPING_MS = 1500 - DEBOUNCED_INVALID_REVERT_MS = 1000 - FOCUSOUT_INVALID_REVERT_MS = 1200 - - last_valid_value = var.get() - - def validate_value(value: str, revert_delay_ms: int | None) -> bool: - nonlocal revert_after_id, last_valid_value - meta = ui_state.get_field_metadata(var_name) - declared_type = meta.type - nullable = meta.nullable - default_val = meta.default - - if revert_after_id: - with contextlib.suppress(Exception): - component.after_cancel(revert_after_id) - revert_after_id = None - - def success(): - nonlocal last_valid_value - component.configure(border_color=original_border_color) - last_valid_value = value - return True - - def do_revert(): - var.set(last_valid_value) - component.configure(border_color=original_border_color) - - def fail(_reason: str): - nonlocal revert_after_id - component.configure(border_color=error_border_color) - if revert_delay_ms is not None: - revert_after_id = component.after(revert_delay_ms, do_revert) - else: - do_revert() - return False - - if value == "": - if nullable: - return success() - if declared_type is str: - if default_val == "": - return success() - return fail("Value required") - - try: - if declared_type is int: - int(value) - elif declared_type is float: - float(value) - elif declared_type is bool: - if value.lower() not in ("true", "false", "0", "1"): - return fail("Invalid bool") - return success() - except ValueError: - return fail("Invalid value") - - def debounced_validate(*_): - nonlocal validation_after_id, revert_after_id - if not touched: - if validation_after_id: - with contextlib.suppress(Exception): - component.after_cancel(validation_after_id) - validation_after_id = None - return - if revert_after_id: - with contextlib.suppress(Exception): - component.after_cancel(revert_after_id) - revert_after_id = None - if validation_after_id: - with contextlib.suppress(Exception): - component.after_cancel(validation_after_id) - validation_after_id = component.after( - DEBOUNCE_STOP_TYPING_MS, - lambda: validate_value(var.get(), DEBOUNCED_INVALID_REVERT_MS) + if validator_factory is not None: + validator = validator_factory( + component, var, ui_state, var_name, + max_undo=max_undo or DEFAULT_MAX_UNDO, + extra_validate=extra_validate, + required=required, ) - - validation_trace_name = var.trace_add("write", debounced_validate) - - def on_focus_in(_e=None): - nonlocal touched - touched = False - - def on_user_input(_e=None): - nonlocal touched - touched = True - - def on_focus_out(_e=None): - # only validate on focus-out if the user interacted with the field. - if touched: - validate_value(var.get(), FOCUSOUT_INVALID_REVERT_MS) - - component.bind("", on_focus_in) - component.bind("", on_user_input) - component.bind("<>", on_user_input) - component.bind("<>", on_user_input) - component.bind("", on_focus_out) + else: + validator = FieldValidator( + component, var, ui_state, var_name, + max_undo=max_undo or DEFAULT_MAX_UNDO, + extra_validate=extra_validate, + required=required, + ) + validator.attach() + component._validator = validator # type: ignore[attr-defined] original_destroy = component.destroy def new_destroy(): + validator.detach() + # 'temporary' fix until https://github.com/TomSchimansky/CustomTkinter/pull/2077 is merged # unfortunately Tom has admitted to forgetting about how to maintain CTK so this likely will never be merged - nonlocal validation_after_id, revert_after_id if component._textvariable_callback_name: - component._textvariable.trace_remove("write", component._textvariable_callback_name) + with contextlib.suppress(tk.TclError): + component._textvariable.trace_remove("write", component._textvariable_callback_name) # type: ignore[union-attr] component._textvariable_callback_name = "" - if validation_after_id: - with contextlib.suppress(Exception): - component.after_cancel(validation_after_id) - if revert_after_id: - with contextlib.suppress(Exception): - component.after_cancel(revert_after_id) - - var.trace_remove("write", validation_trace_name) - if command is not None and trace_id is not None: ui_state.remove_var_trace(var_name, trace_id) original_destroy() - component.destroy = new_destroy + component.destroy = new_destroy # type: ignore[assignment] if tooltip: ToolTip(component, tooltip, wide=wide_tooltip) @@ -199,76 +108,95 @@ def new_destroy(): return component -def file_entry( +def json_path_modifier(x: str | Path) -> Path: + x = Path(x).absolute() + return x.parent if x.suffix == ".json" else x + + +def path_entry( master, row, column, ui_state: UIState, var_name: str, - is_output: bool = False, - path_modifier: Callable[[str], str] = None, + *, + mode: Literal["file", "dir"] = "file", + io_type: PathIOType = PathIOType.INPUT, + path_modifier: Callable[[str], str | Path] | None = None, allow_model_files: bool = True, allow_image_files: bool = False, - command: Callable[[str], None] = None, + command: Callable[[str], None] | None = None, + extra_validate: Callable[[str], str | None] | None = None, + required: bool = False, ): frame = ctk.CTkFrame(master, fg_color="transparent") frame.grid(row=row, column=column, padx=0, pady=0, sticky="new") frame.grid_columnconfigure(0, weight=1) - entry(frame,row=0, column=0, ui_state=ui_state, var_name=var_name) + def _path_validator_factory(comp, var, state, name, **kw): + return PathValidator(comp, var, state, name, io_type=io_type, **kw) + + entry_component = entry( + frame, row=0, column=0, ui_state=ui_state, var_name=var_name, + validator_factory=_path_validator_factory, + extra_validate=extra_validate, + required=required, + ) + + trace_ids = [] + if io_type in (PathIOType.OUTPUT, PathIOType.MODEL): + validator = getattr(entry_component, '_validator', None) + if validator is not None: + for dep_var_name in ("prevent_overwrites", "output_model_format"): + with contextlib.suppress(KeyError, AttributeError): + dep_var = ui_state.get_var(dep_var_name) + tid = dep_var.trace_add("write", lambda *_a: validator.revalidate()) + trace_ids.append((dep_var, tid)) + + use_save_dialog = io_type in (PathIOType.OUTPUT, PathIOType.MODEL) def __open_dialog(): - filetypes = [ - ("All Files", "*.*"), - ] - - if allow_model_files: - filetypes.extend([ - ("Diffusers", "model_index.json"), - ("Checkpoint", "*.ckpt *.pt *.bin"), - ("Safetensors", "*.safetensors"), - ]) - if allow_image_files: - filetypes.extend([ - ("Image", ' '.join([f"*.{x}" for x in supported_image_extensions()])), - ]) - - if is_output: - file_path = filedialog.asksaveasfilename(filetypes=filetypes) + if mode == "dir": + chosen = filedialog.askdirectory() else: - file_path = filedialog.askopenfilename(filetypes=filetypes) + filetypes = [ + ("All Files", "*.*"), + ] + + if allow_model_files: + filetypes.extend([ + ("Diffusers", "model_index.json"), + ("Checkpoint", "*.ckpt *.pt *.bin"), + ("Safetensors", "*.safetensors"), + ]) + if allow_image_files: + filetypes.extend([ + ("Image", ' '.join([f"*.{x}" for x in supported_image_extensions()])), + ]) + + if use_save_dialog: + chosen = filedialog.asksaveasfilename(filetypes=filetypes) + else: + chosen = filedialog.askopenfilename(filetypes=filetypes) - if file_path: + if chosen: if path_modifier: - file_path = path_modifier(file_path) + chosen = path_modifier(chosen) - ui_state.get_var(var_name).set(file_path) + chosen_str = str(chosen) + ui_state.get_var(var_name).set(chosen_str) if command: - command(file_path) + command(chosen_str) button_component = ctk.CTkButton(frame, text="...", width=40, command=__open_dialog) button_component.grid(row=0, column=1, padx=(0, PAD), pady=PAD, sticky="nsew") - return frame - - -def dir_entry(master, row, column, ui_state: UIState, var_name: str, command: Callable[[str], None] = None): - frame = ctk.CTkFrame(master, fg_color="transparent") - frame.grid(row=row, column=column, padx=0, pady=0, sticky="new") - - frame.grid_columnconfigure(0, weight=1) - - entry(frame, row=0, column=0, ui_state=ui_state, var_name=var_name) - - def __open_dialog(): - dir_path = filedialog.askdirectory() - - if dir_path: - ui_state.get_var(var_name).set(dir_path) - - if command: - command(dir_path) - - button_component = ctk.CTkButton(frame, text="...", width=40, command=__open_dialog) - button_component.grid(row=0, column=1, padx=(0, PAD), pady=PAD, sticky="nsew") + if trace_ids: + original_frame_destroy = frame.destroy + def _frame_destroy(): + for dep_var, tid in trace_ids: + with contextlib.suppress(tk.TclError, ValueError): + dep_var.trace_remove("write", tid) + original_frame_destroy() + frame.destroy = _frame_destroy # type: ignore[assignment] return frame @@ -422,7 +350,7 @@ def button(master, row, column, text, command, tooltip=None, **kwargs): return component -def options(master, row, column, values, ui_state: UIState, var_name: str, command: Callable[[str], None] = None): +def options(master, row, column, values, ui_state: UIState, var_name: str, command: Callable[[str], None] | None = None): component = ctk.CTkOptionMenu(master, values=values, variable=ui_state.get_var(var_name), command=command) component.grid(row=row, column=column, padx=PAD, pady=(PAD, PAD), sticky="new") @@ -437,13 +365,13 @@ def destroy(self): return destroy destroy = create_destroy(component._dropdown_menu) - component._dropdown_menu.destroy = lambda: destroy(component._dropdown_menu) + component._dropdown_menu.destroy = lambda: destroy(component._dropdown_menu) # type: ignore[assignment] return component def options_adv(master, row, column, values, ui_state: UIState, var_name: str, - command: Callable[[str], None] = None, adv_command: Callable[[], None] = None): + command: Callable[[str], None] | None = None, adv_command: Callable[[], None] | None = None): frame = ctk.CTkFrame(master, fg_color="transparent") frame.grid(row=row, column=column, padx=0, pady=0, sticky="new") @@ -469,13 +397,13 @@ def destroy(self): return destroy destroy = create_destroy(component._dropdown_menu) - component._dropdown_menu.destroy = lambda: destroy(component._dropdown_menu) + component._dropdown_menu.destroy = lambda: destroy(component._dropdown_menu) # type: ignore[assignment] return frame, {'component': component, 'button_component': button_component} def options_kv(master, row, column, values: list[tuple[str, Any]], ui_state: UIState, var_name: str, - command: Callable[[Any], None] = None): + command: Callable[[Any], None] | None = None): var = ui_state.get_var(var_name) keys = [key for key, value in values] @@ -523,7 +451,7 @@ def destroy(self): return destroy destroy = create_destroy(component._dropdown_menu) - component._dropdown_menu.destroy = lambda: destroy(component._dropdown_menu) + component._dropdown_menu.destroy = lambda: destroy(component._dropdown_menu) # type: ignore[assignment] return component @@ -534,7 +462,7 @@ def switch( column, ui_state: UIState, var_name: str, - command: Callable[[], None] = None, + command: Callable[[], None] | None = None, text: str = "", ): var = ui_state.get_var(var_name) @@ -556,7 +484,7 @@ def destroy(self): return destroy destroy = create_destroy(component) - component.destroy = lambda: destroy(component) + component.destroy = lambda: destroy(component) # type: ignore[assignment] return component diff --git a/modules/util/ui/ui_utils.py b/modules/util/ui/ui_utils.py index ba09e1038..c1c9d0a68 100644 --- a/modules/util/ui/ui_utils.py +++ b/modules/util/ui/ui_utils.py @@ -1,4 +1,3 @@ -import contextlib import platform import sys import tkinter as tk @@ -100,22 +99,3 @@ def set_icon(): except Exception as e: print(f"Failed to set window icon: {e}") - -class DebounceTimer: - def __init__(self, widget, delay_ms: int, callback: Callable[..., Any]): - self.widget = widget - self.delay_ms = delay_ms - self.callback = callback - self._after_id: str | None = None - - def call(self, *args, **kwargs): - if self._after_id: - with contextlib.suppress(tk.TclError): - self.widget.after_cancel(self._after_id) - - def fire(): - self._after_id = None - self.callback(*args, **kwargs) - - with contextlib.suppress(tk.TclError): - self._after_id = self.widget.after(self.delay_ms, fire) diff --git a/modules/util/ui/validation.py b/modules/util/ui/validation.py new file mode 100644 index 000000000..9f44de8eb --- /dev/null +++ b/modules/util/ui/validation.py @@ -0,0 +1,501 @@ +from __future__ import annotations + +import contextlib +import os +import re +import sys +import tkinter as tk +from collections import deque +from collections.abc import Callable +from pathlib import PurePosixPath, PureWindowsPath +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +from modules.util.enum.ModelFormat import ModelFormat +from modules.util.enum.PathIOType import PathIOType + +if TYPE_CHECKING: + from modules.util.ui.UIState import UIState + + import customtkinter as ctk + + +DEBOUNCE_TYPING_MS = 250 +UNDO_DEBOUNCE_MS = 500 +ERROR_BORDER_COLOR = "#dc3545" + +_active_validators: set[FieldValidator] = set() + +TRAILING_SLASH_RE = re.compile(r"[\\/]$") +ENDS_WITH_EXT = re.compile(r"\.[A-Za-z0-9]+$") +HUGGINGFACE_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") + +_INVALID_CHARS = {chr(c) for c in range(32)} +_IS_WINDOWS = sys.platform == "win32" +if _IS_WINDOWS: + _INVALID_CHARS |= set('<>"|?*') + + +def _is_huggingface_repo_or_file(value: str) -> bool: + trimmed = value.strip() + + if trimmed.startswith("https://"): + parsed = urlparse(trimmed) + if parsed.netloc not in {"huggingface.co", "huggingface.com"}: + return False + parts = parsed.path.strip("/").split("/") + if len(parts) >= 5 and parts[2] in {"resolve", "blob"}: + return bool(ENDS_WITH_EXT.search(parts[-1])) + return False + + if len(trimmed) > 96: + return False + if " " in trimmed or "\t" in trimmed: + return False + if "—" in trimmed or ".." in trimmed: + return False + if trimmed.startswith(("\\\\", "//", "/")): + return False + if len(trimmed) >= 2 and trimmed[1] == ":" and trimmed[0].isalpha(): + return False + if trimmed.count("/") != 1: + return False + + return bool(HUGGINGFACE_REPO_RE.match(trimmed)) + + +def _has_invalid_chars(value: str) -> bool: + return bool(_INVALID_CHARS.intersection(value)) + + +def _check_overwrite(path: str, *, is_dir: bool, prevent: bool) -> str | None: + if not prevent: + return None + abs_path = os.path.abspath(path) + if is_dir and os.path.isdir(abs_path): + return "Output folder already exists (overwrite prevented)" + if not is_dir and os.path.isfile(abs_path): + return "Output file already exists (overwrite prevented)" + return None + + +def validate_path( + value: str, + io_type: PathIOType = PathIOType.INPUT, + *, + prevent_overwrites: bool = False, + output_format: str | None = None, +) -> str | None: + """Return an error string if *value* is an invalid path, else ``None``.""" + trimmed = value.strip() + + if not trimmed: + return "Path is empty" + if TRAILING_SLASH_RE.search(trimmed): + return "Path must not end with a slash" + if _has_invalid_chars(trimmed): + return "Path contains invalid characters" + + if trimmed.startswith("cloud:"): + cloud_path = trimmed[6:] + if not cloud_path: + return "Cloud path is empty" + if cloud_path.startswith(("http://", "https://")): + return "Cloud path cannot be a URL" + if "\\" in cloud_path: + return "Cloud path must use forward slashes (/)" + return None + + if io_type == PathIOType.INPUT and _is_huggingface_repo_or_file(trimmed): + return None + + if io_type == PathIOType.INPUT: + if not os.path.exists(os.path.abspath(trimmed)): + return "Input path does not exist" + + if io_type in (PathIOType.OUTPUT, PathIOType.MODEL): + if not os.path.isdir(os.path.dirname(os.path.abspath(trimmed))): + return "Parent folder does not exist" + + if io_type == PathIOType.MODEL and output_format is not None: + if output_format == "DIFFUSERS": + if ENDS_WITH_EXT.search(trimmed): + return "Diffusers output must be a directory path, not a file" + return _check_overwrite(trimmed, is_dir=True, prevent=prevent_overwrites) + + try: + expected_ext = ModelFormat[output_format].file_extension() + except KeyError: + expected_ext = "" + + if expected_ext: + suffix = (PureWindowsPath(trimmed) if _IS_WINDOWS else PurePosixPath(trimmed)).suffix.lower() + if suffix != expected_ext: + return f"Extension must be '{expected_ext}' for {output_format} format" + return _check_overwrite(trimmed, is_dir=False, prevent=prevent_overwrites) + + if io_type == PathIOType.OUTPUT: + return _check_overwrite(trimmed, is_dir=False, prevent=prevent_overwrites) + + return None + +DEFAULT_MAX_UNDO = 20 + + +class UndoHistory: + def __init__(self, max_size: int = DEFAULT_MAX_UNDO): + self._stack: deque[str] = deque(maxlen=max_size) + self._redo_stack: list[str] = [] + + def push(self, value: str): + if self._stack and self._stack[-1] == value: + return + self._stack.append(value) + self._redo_stack.clear() + + def undo(self, current: str) -> str | None: + if not self._stack: + return None + top = self._stack[-1] + if top == current and len(self._stack) > 1: + self._redo_stack.append(self._stack.pop()) + return self._stack[-1] + elif top != current: + self._redo_stack.append(current) + return top + return None + + def redo(self) -> str | None: + if not self._redo_stack: + return None + value = self._redo_stack.pop() + self._stack.append(value) + return value + + +class DebounceTimer: + def __init__(self, widget, delay_ms: int, callback: Callable[..., Any]): + self.widget = widget + self.delay_ms = delay_ms + self.callback = callback + self._after_id: str | None = None + + def call(self, *args, **kwargs): + if self._after_id: + with contextlib.suppress(tk.TclError): + self.widget.after_cancel(self._after_id) + + def fire(): + self._after_id = None + self.callback(*args, **kwargs) + + with contextlib.suppress(tk.TclError): + self._after_id = self.widget.after(self.delay_ms, fire) + + def cancel(self): + if self._after_id: + with contextlib.suppress(tk.TclError): + self.widget.after_cancel(self._after_id) + self._after_id = None + + +class FieldValidator: + def __init__( + self, + component: ctk.CTkEntry, + var: tk.Variable, + ui_state: UIState, + var_name: str, + max_undo: int = DEFAULT_MAX_UNDO, + extra_validate: Callable[[str], str | None] | None = None, + required: bool = False, + ): + self.component = component + self.var = var + self.ui_state = ui_state + self.var_name = var_name + self._extra_validate = extra_validate + self._required = required + + try: + self._original_border_color = component.cget("border_color") + except Exception: + self._original_border_color = "gray50" + + self._shadow_var = tk.StringVar(master=component) + self._shadow_trace_name: str | None = None + self._real_var_trace_name: str | None = None + self._syncing = False + self._touched = False + self._bound = False + + self._debounce: DebounceTimer | None = None + self._undo_debounce: DebounceTimer | None = None + self._undo = UndoHistory(max_undo) + + def attach(self) -> None: + self._shadow_var.set(self.var.get()) + self._swap_textvariable(self._shadow_var) + + self._debounce = DebounceTimer( + self.component, DEBOUNCE_TYPING_MS, self._on_debounce_fire + ) + self._undo_debounce = DebounceTimer( + self.component, UNDO_DEBOUNCE_MS, self._push_undo_snapshot + ) + + self._shadow_trace_name = self._shadow_var.trace_add("write", self._on_shadow_write) + self._real_var_trace_name = self.var.trace_add("write", self._on_real_var_write) + + self.component.bind("", self._on_focus_in) + self.component.bind("", self._on_user_input) + self.component.bind("<>", self._on_user_input) + self.component.bind("<>", self._on_user_input) + self.component.bind("", self._on_focus_out) + self.component.bind("", self._on_undo) + self.component.bind("", self._on_undo) + self.component.bind("", self._on_redo) + self.component.bind("", self._on_redo) + self.component.bind("", self._on_redo) + self.component.bind("", self._on_redo) + self.component.bind("", self._on_enter) + + self._bound = True + _active_validators.add(self) + + def detach(self) -> None: + if not self._bound: + return + self._bound = False + _active_validators.discard(self) + + self._commit() + + if self._debounce: + self._debounce.cancel() + if self._undo_debounce: + self._undo_debounce.cancel() + + if self._shadow_trace_name: + with contextlib.suppress(Exception): + self._shadow_var.trace_remove("write", self._shadow_trace_name) + self._shadow_trace_name = None + + if self._real_var_trace_name: + with contextlib.suppress(Exception): + self.var.trace_remove("write", self._real_var_trace_name) + self._real_var_trace_name = None + + self._swap_textvariable(self.var) + + def _swap_textvariable(self, new_var: tk.Variable) -> None: + comp = self.component + if comp._textvariable_callback_name: + with contextlib.suppress(Exception): + comp._textvariable.trace_remove("write", comp._textvariable_callback_name) # type: ignore[union-attr] + comp._textvariable_callback_name = "" + + comp.configure(textvariable=new_var) + + if new_var is not None: + comp._textvariable_callback_name = new_var.trace_add( + "write", comp._textvariable_callback + ) + + def _commit(self) -> None: + shadow_val = self._shadow_var.get() + if shadow_val != self.var.get(): + self._syncing = True + self.var.set(shadow_val) + self._syncing = False + + def validate(self, value: str) -> str | None: + """Return an error string if *value* is invalid, else None.""" + meta = self.ui_state.get_field_metadata(self.var_name) + declared_type = meta.type + nullable = meta.nullable + default_val = meta.default + + if value == "": + if self._required: + return "Value required" + if nullable: + return None + if declared_type is str: + if default_val == "": + return None + return "Value required" + return None + + try: + if declared_type is int: + v = int(value) + if v < 0: + return "Value must be non-negative" + elif declared_type is float: + v = float(value) + if v < 0: + return "Value must be non-negative" + elif declared_type is bool: + if value.lower() not in ("true", "false", "0", "1"): + return "Invalid bool" + except ValueError: + return "Invalid value" + + if self._extra_validate is not None: + return self._extra_validate(value) + + return None + + def _apply_error(self) -> None: + self.component.configure(border_color=ERROR_BORDER_COLOR) + + def _clear_error(self) -> None: + self.component.configure(border_color=self._original_border_color) + + def _validate_and_style(self, value: str) -> bool: + error = self.validate(value) + if error is None: + self._clear_error() + return True + else: + self._apply_error() + return False + + def _on_shadow_write(self, *_args) -> None: + if self._syncing: + return + if not self._touched: + # external sync or initial set — commit immediately + self._commit() + if self._debounce: + self._debounce.cancel() + return + if self._debounce: + self._debounce.call() + if self._undo_debounce: + self._undo_debounce.call() + + def _on_real_var_write(self, *_args) -> None: + if self._syncing: + return + # external change (preset load, file dialog, etc) — sync to shadow var + self._syncing = True + self._shadow_var.set(self.var.get()) + self._syncing = False + self._validate_and_style(self._shadow_var.get()) + + def _push_undo_snapshot(self) -> None: + self._undo.push(self._shadow_var.get()) + + def _on_debounce_fire(self) -> None: + val = self._shadow_var.get() + if self._validate_and_style(val): + self._commit() + + def _on_focus_in(self, _e=None) -> None: + self._touched = False + self._undo.push(self._shadow_var.get()) + + def _on_user_input(self, _e=None) -> None: + self._touched = True + + def _on_focus_out(self, _e=None) -> None: + if self._debounce: + self._debounce.cancel() + if self._undo_debounce: + self._undo_debounce.cancel() + if self._touched: + if self._validate_and_style(self._shadow_var.get()): + self._commit() + self._undo.push(self._shadow_var.get()) + + def _on_enter(self, _e=None) -> None: + if self._debounce: + self._debounce.cancel() + if self._touched: + if self._validate_and_style(self._shadow_var.get()): + self._commit() + + def _set_value(self, value: str) -> None: + self._syncing = True + self._shadow_var.set(value) + self._syncing = False + if self._validate_and_style(value): + self._commit() + + def _on_undo(self, _e=None) -> str: + previous = self._undo.undo(self._shadow_var.get()) + if previous is not None: + self._set_value(previous) + return "break" + + def _on_redo(self, _e=None) -> str: + next_val = self._undo.redo() + if next_val is not None: + self._set_value(next_val) + return "break" + + +class PathValidator(FieldValidator): + """FieldValidator with additional path-specific checks.""" + + def __init__( + self, + component: ctk.CTkEntry, + var: tk.Variable, + ui_state: UIState, + var_name: str, + io_type: PathIOType = PathIOType.INPUT, + max_undo: int = DEFAULT_MAX_UNDO, + extra_validate: Callable[[str], str | None] | None = None, + required: bool = False, + ): + super().__init__(component, var, ui_state, var_name, max_undo=max_undo, extra_validate=extra_validate, required=required) + self.io_type = io_type + + def _get_var_safe(self, name: str) -> tk.Variable | None: + try: + return self.ui_state.get_var(name) + except (KeyError, AttributeError): + return None + + def validate(self, value: str) -> str | None: + base_err = super().validate(value) + if base_err is not None: + return base_err + if value == "": + return None + + prevent_var = self._get_var_safe("prevent_overwrites") + format_var = self._get_var_safe("output_model_format") + return validate_path( + value, + io_type=self.io_type, + prevent_overwrites=prevent_var.get() if prevent_var is not None else False, + output_format=format_var.get() if format_var is not None else None, + ) + + def revalidate(self) -> None: + if self.component.winfo_exists(): + self._validate_and_style(self._shadow_var.get()) + + +def flush_and_validate_all() -> list[str]: + invalid: list[str] = [] + + for v in list(_active_validators): + if v._debounce: + v._debounce.cancel() + + value = v._shadow_var.get() + error = v.validate(value) + + if error is not None: + v._apply_error() + invalid.append(f"{v.var_name}: {error}") + else: + v._clear_error() + v._commit() + + return invalid diff --git a/modules/util/ui/validation_helpers.py b/modules/util/ui/validation_helpers.py new file mode 100644 index 000000000..1ae1c504e --- /dev/null +++ b/modules/util/ui/validation_helpers.py @@ -0,0 +1,90 @@ +from __future__ import annotations + +from collections.abc import Callable + + +def validate_resolution() -> Callable[[str], str | None]: + """Return a resolution validator.""" + + def _check(value: str) -> str | None: + value = value.strip() + if not value: + return None + + dims = [] + + if 'x' in value: + parts = value.split('x') + if len(parts) == 2 and parts[0].strip().isdigit() and parts[1].strip().isdigit(): + dims = [int(parts[0].strip()), int(parts[1].strip())] + else: + return "Invalid format. Use x (e.g., 1024x768)" + + else: + parts = value.split(',') + if all(p.strip().isdigit() for p in parts): + dims = [int(p.strip()) for p in parts] + else: + return "Must be a single integer, x, or comma-separated integers" + + for d in dims: + if d <= 0: + return f"Resolution cannot be less than or equal to 0 (found {d})." + + return None + + return _check + + +def check_range( + *, + lower: float | None = None, + upper: float | None = None, + lower_inclusive: bool = True, + upper_inclusive: bool = True, + message: str | None = None, +) -> Callable[[str], str | None]: + """Validate that a numeric value falls within specified range, by default both bounds are inclusive.""" + + def _check(value: str) -> str | None: + try: + v = float(value) + except (ValueError, TypeError): + return None # type checking is handled by baseline validation + + if lower is not None: + if lower_inclusive and v < lower: + return message or f"Value must be at least {lower}" + if not lower_inclusive and v <= lower: + return message or f"Value must be greater than {lower}" + + if upper is not None: + if upper_inclusive and v > upper: + return message or f"Value must be at most {upper}" + if not upper_inclusive and v >= upper: + return message or f"Value must be less than {upper}" + + return None + + return _check + + +def compose(*checks: Callable[[str], str | None]) -> Callable[[str], str | None]: + """Chain multiple ``extra_validate`` checks; return the first error. + + Usage:: + + extra_validate=compose( + check_range(lower=0, upper=1), + , + ) + """ + + def _check(value: str) -> str | None: + for fn in checks: + err = fn(value) + if err is not None: + return err + return None + + return _check