Files
MohammadAboulEla-ComfyUI-iT…/iTools_nodes.py
T

722 lines
28 KiB
Python

import hashlib
import os
import time
from pathlib import Path
import random
from PIL.PngImagePlugin import PngInfo
import comfy.samplers
import folder_paths
import node_helpers
import numpy as np
import torch
from PIL import Image, ImageSequence, ImageOps
from .backend.checker_board import ChessTensor, ChessPattern
from nodes import common_ksampler
import json
from .backend.file_handeler import FileHandler
from .backend.grid_filler import fill_grid_with_images_new, tensor_to_images, image_to_tensor
from .backend.metadata_extractor import get_prompt
from .backend.overlay import add_text_bar
from .backend.prompter import read_replace_and_combine, templates
from .backend.prompter_multi import combine_multi, templates_basic, templates_extra1, templates_extra2, \
templates_extra3
from .backend.shared import styles, tensor2pil, pil2tensor, project_dir
from comfy.cli_args import args
class IToolsLoadImagePlus:
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))]
return {"required":
{"image": (sorted(files), {"image_upload": True})},
}
CATEGORY = "iTools"
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "STRING")
RETURN_NAMES = ("IMAGE", "MASK", "possible prompt", "image name")
FUNCTION = "load_image"
DESCRIPTION = ("An enhancement of the original ComfyUI ImageLoader node. It attempts to return the possible prompt "
"used to create an image.")
def load_image(self, image):
image_path = folder_paths.get_annotated_filepath(image)
filename = image.rsplit('.', 1)[0] # get image name
img = node_helpers.pillow(Image.open, image_path)
output_images = []
output_masks = []
w, h = None, None
excluded_formats = ['MPO']
for i in ImageSequence.Iterator(img):
i = node_helpers.pillow(ImageOps.exif_transpose, i)
if i.mode == 'I':
i = i.point(lambda i: i * (1 / 255))
image = i.convert("RGB")
if len(output_images) == 0:
w = image.size[0]
h = image.size[1]
if image.size[0] != w or image.size[1] != h:
continue
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
if 'A' in i.getbands():
mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0
mask = 1. - torch.from_numpy(mask)
else:
mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
output_images.append(image)
output_masks.append(mask.unsqueeze(0))
if len(output_images) > 1 and img.format not in excluded_formats:
output_image = torch.cat(output_images, dim=0)
output_mask = torch.cat(output_masks, dim=0)
else:
output_image = output_images[0]
output_mask = output_masks[0]
output_prompt = get_prompt(image_path)
return (output_image, output_mask, output_prompt, filename)
@classmethod
def IS_CHANGED(cls, image):
image_path = folder_paths.get_annotated_filepath(image)
m = hashlib.sha256()
with open(image_path, 'rb') as f:
m.update(f.read())
return m.digest().hex()
@classmethod
def VALIDATE_INPUTS(cls, image):
if not folder_paths.exists_annotated_filepath(image):
return "Invalid image file: {}".format(image)
return True
class IToolsPromptLoader:
@classmethod
def INPUT_TYPES(s):
return {"required":
{
"file_path": ("STRING", {"default": 'prompts.txt', "multiline": False}),
"seed": ("INT", {"default": 0, "control_after_generate": 0, "min": 0, "max": 0xffff}),
}
}
CATEGORY = "iTools"
RETURN_TYPES = ("STRING", "INT")
RETURN_NAMES = ("prompt", "count")
FUNCTION = "load_file"
DESCRIPTION = ("Will return a prompt (line number) from txt file at given "
"index, note that count start from zero.")
def load_file(self, file_path, seed, fallback="Yes"):
prompt = ""
count = 0
if file_path == "prompts.txt":
file = os.path.join(project_dir, "examples", "prompts.txt")
else:
file = file_path.replace('"', '')
if os.path.exists(file):
fh = FileHandler(file)
try:
count = fh.len_lines()
line = fh.read_line(seed)
prompt = fh.unescape_quotes(line)
except IndexError:
if fallback == "Yes":
seed = seed % fh.len_lines()
line = fh.read_line(seed)
prompt = fh.unescape_quotes(line)
else:
prompt = f"File not exist, {file}"
return prompt, count
class IToolsPromptSaver:
@classmethod
def INPUT_TYPES(s):
return {"required":
{"prompt": ("STRING", {"forceInput": True}),
"file_path": ("STRING", {"default": 'prompts.txt', "multiline": False}),
}
}
CATEGORY = "iTools"
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "save_to_file"
DESCRIPTION = "Will append the given prompt as a new line to the given txt file"
def save_to_file(self, file_path, prompt):
if file_path == "prompts.txt":
file = os.path.join(project_dir, "examples", "prompts.txt")
else:
file = file_path.replace('"', '')
if os.path.exists(file) and prompt is not None and prompt != "":
fh = FileHandler(file)
try:
fh.append_line(prompt)
print(f"Prompt: {prompt} saved to {file}")
except Exception as e:
print(f"Error while writing the prompt: {e}")
else:
print(f"Error while writing the prompt")
return (True,)
class IToolsPromptStyler:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text_positive": ("STRING", {"default": "", "multiline": True}),
"text_negative": ("STRING", {"default": "", "multiline": False}),
"style_file": (styles, {"default": "basic.yaml"}),
"template_name": (templates,),
},
}
@classmethod
def VALIDATE_INPUTS(cls, template_name):
# YOLO, anything goes!
return True
def IS_CHANGED(text_positive, text_negative, template_name, style_file):
if template_name == "random":
return float("nan") # Force re-execution if template is "random"
RETURN_TYPES = ('STRING', 'STRING',)
RETURN_NAMES = ('positive_prompt', 'negative_prompt',)
FUNCTION = 'prompt_styler'
CATEGORY = 'iTools'
DESCRIPTION = "Helps you quickly populate your prompt using a template stored in YAML file."
def prompt_styler(self, text_positive, text_negative, template_name, style_file):
positive_prompt, negative_prompt = read_replace_and_combine(template_name, text_positive,
text_negative, style_file)
return positive_prompt, negative_prompt
class IToolsAddOverlay:
@classmethod
def INPUT_TYPES(s):
return {"required":
{
"image": ("IMAGE", {}),
"text": ("STRING", {"default": 'img info:', "multiline": False}),
"background_color": ("STRING", {"default": '#000000AA', "multiline": False}),
"font_size": ("INT", {"default": 40, "min": 10, "max": 1000}),
"overlay_mode": ("BOOLEAN", {"default": True}),
}
}
CATEGORY = "iTools"
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "add_text_overlay"
DESCRIPTION = ("Will add an overlay bottom bar to show a given text, you may change the background color of the "
"overlay bar and the font size.")
def add_text_overlay(self, image, text, font_size, background_color, overlay_mode):
# Convert image to tensor
pil_image = tensor2pil(image)
# Add overlay or underlay
if overlay_mode:
composite = add_text_bar(pil_image, text, font_size=font_size, background_color=background_color,position="overlay")
else:
composite = add_text_bar(pil_image, text, font_size=font_size, background_color=background_color,position="underlay")
# Convert back to tensor
out = pil2tensor(composite)
return (out,)
class IToolsLoadImages:
@classmethod
def INPUT_TYPES(s):
return {"required": {
"images_directory": ("STRING", {"multiline": False}),
"start_index": ("INT", {"default": 0, "min": 0, "max": 200}),
"load_limit": ("INT", {"default": 4, "min": 2, "max": 200})
}}
RETURN_TYPES = ('IMAGE', "STRING", "INT")
RETURN_NAMES = ('images', 'images names', 'count')
FUNCTION = 'load_images'
CATEGORY = 'iTools'
OUTPUT_IS_LIST = (True, True, False)
DESCRIPTION = ("Will return list of images from a given directory with a given limit, for example if the limit is "
"4 it will return first 4 images in that directory. it will also return the list of these images "
"names.")
def load_images(self, images_directory, load_limit, start_index):
image_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'}
images_path = Path(images_directory.replace('"', ''))
if not images_path.exists():
raise FileNotFoundError(f"Image directory {images_directory} does not exist")
images = []
images_names = []
for idx, image_path in enumerate(images_path.iterdir()):
if idx < start_index:
continue # Skip images until reaching the start_index
if image_path.suffix.lower() in image_extensions:
images.append(pil2tensor(Image.open(image_path)))
images_names.append(image_path.stem) # Add the image name without extension
if len(images) >= load_limit:
break
return images, images_names, len(images)
class IToolsPromptStylerExtra:
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text_positive": ("STRING", {"default": "", "multiline": True}),
"text_negative": ("STRING", {"default": "", "multiline": False}),
"base_file": ((styles), {"default": "basic.yaml"}),
"base_style": ((templates_basic),),
"second_file": ((styles), {"default": "camera.yaml"}),
"second_style": ((templates_extra1),),
"third_file": ((styles), {"default": "artist.yaml"}),
"third_style": ((templates_extra2),),
"fourth_file": ((styles), {"default": "mood.yaml"}),
"fourth_style": ((templates_extra3),),
},
}
@classmethod
def VALIDATE_INPUTS(cls,
base_style,
second_style,
third_style,
fourth_style):
# YOLO, anything goes!
return True
def IS_CHANGED(text_positive, text_negative,
base_file, base_style,
second_file,second_style,
third_file,third_style,
fourth_file,fourth_style
):
if base_style == "random" or second_style == "random" or third_style == "random" or fourth_style== "random":
return float("nan") # Force re-execution if template is "random"
RETURN_TYPES = ('STRING', 'STRING', 'STRING',)
RETURN_NAMES = ('positive_prompt', 'negative_prompt', 'used_templates')
FUNCTION = 'prompt_styler_extra'
CATEGORY = 'iTools'
DESCRIPTION = ("Helps you quickly populate your prompt using templates from up to 4 YAML files.")
def prompt_styler_extra(self, text_positive, text_negative,
base_file, base_style,
second_file, second_style,
third_file, third_style,
fourth_file, fourth_style,
):
positive_prompt, negative_prompt, _templates = combine_multi(
text_positive, text_negative,
base_file, base_style,
second_file, second_style,
third_file, third_style,
fourth_file,
fourth_style, )
return positive_prompt, negative_prompt, _templates
class IToolsGridFiller:
@classmethod
def INPUT_TYPES(s):
directions = ["rows", "cols"]
return {"required":
{
"images": ("IMAGE", {}),
"width": ("INT", {"default": 1024, "min": 256, "max": 8192}),
"height": ("INT", {"default": 1024, "min": 256, "max": 8192}),
"rows": ("INT", {"default": 3, "min": 1, "max": 10}),
"cols": ("INT", {"default": 3, "min": 1, "max": 10}),
"gaps": ("FLOAT", {"default": 2, "min": 0.0, "max": 50, "steps": 1}),
"background_color": ("STRING", {"default": '#000000AA', "multiline": False}),
"fill_direction": (directions, {"default": directions[0]}),
}
}
RETURN_TYPES = ('IMAGE',)
RETURN_NAMES = ('images',)
FUNCTION = 'fill_grid'
CATEGORY = 'iTools'
INPUT_IS_LIST = (True, False, False, False, False, False, False)
OUTPUT_IS_LIST = (False, False, False, False, False, False, False)
DESCRIPTION = ("Arranging a set of images into specified rows and columns, applying "
"optional spacing and background color")
def fill_grid(self, images, width, height, rows, cols, gaps, background_color, fill_direction):
# Convert tensor to Pillow images
pillow_images = tensor_to_images(images)
# Process images using the provided function
processed_image = fill_grid_with_images_new(pillow_images, rows=rows, cols=cols, grid_size=(width, height),
gap=gaps,
bg_color=background_color, direction=fill_direction[0])
print("fill_direction",fill_direction)
# Convert the processed Pillow image back to a tensor
output_tensor = image_to_tensor(processed_image)
return (output_tensor,)
class IToolsLineLoader:
@classmethod
def INPUT_TYPES(s):
return {"required":
{
"lines": ("STRING", {"default": 'cat\ndog\nbunny', "multiline": True}),
"seed": ("INT", {"default": 0, "control_after_generate": "increment", "min": 0, "max": 0xfff}),
}
}
CATEGORY = "iTools"
RETURN_TYPES = ("STRING", "INT")
RETURN_NAMES = ("line loaded", "count")
FUNCTION = "load_line"
DESCRIPTION = ("Will return a line from a multi line text at given "
"index, note that count start from zero.")
def load_line(self, lines, seed, fallback="Yes"):
# Split the multiline string into individual lines
line_list = lines.splitlines()
# Count the total number of lines
count = len(line_list)
# Check if the seed index is valid
if 0 <= seed < count:
line = line_list[seed]
elif fallback == "Yes" and count > 0:
# If fallback is "Yes", mod the seed by the line count to wrap around
seed_mod = seed % count
line = line_list[seed_mod]
else:
# If the index is out of range and no fallback, return an empty string
line = ""
return line, count
class IToolsTextReplacer:
@classmethod
def INPUT_TYPES(s):
return {"required":
{
"text_in": ("STRING", {"forceInput": True, "multiline": False}),
"match": ("STRING", {"forceInput": False, "multiline": False}),
"replace": ("STRING", {"forceInput": False, "multiline": False}),
}
}
CATEGORY = "iTools"
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("text_out",)
FUNCTION = "replace_text"
DESCRIPTION = "Help you replace a match in a given text."
def replace_text(self, text_in, match, replace):
print(text_in)
return text_in.replace(match, replace),
class IToolsKSampler:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"model": ("MODEL", {"tooltip": "The model used for denoising the input latent."}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff,
"tooltip": "The random seed used for creating the noise."}),
"steps": ("INT", {"default": 20, "min": 1, "max": 10000,
"tooltip": "The number of steps used in the denoising process."}),
"cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step": 0.1, "round": 0.01,
"tooltip": "The Classifier-Free Guidance scale balances creativity and adherence to the prompt. Higher values result in images more closely matching the prompt however too high values will negatively impact quality."}),
"sampler_name": (comfy.samplers.KSampler.SAMPLERS, {
"tooltip": "The algorithm used when sampling, this can affect the quality, speed, and style of the generated output."}),
"scheduler": (comfy.samplers.KSampler.SCHEDULERS,
{"tooltip": "The scheduler controls how noise is gradually removed to form the image."}),
"positive": ("CONDITIONING", {
"tooltip": "The conditioning describing the attributes you want to include in the image."}),
"negative": ("CONDITIONING", {
"tooltip": "The conditioning describing the attributes you want to exclude from the image."}),
"latent_image": ("LATENT", {"tooltip": "The latent image to denoise."}),
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "The amount of denoising applied, lower values will maintain the structure of the initial image allowing for image to image sampling."}),
}
}
RETURN_TYPES = ("LATENT", "STRING")
RETURN_NAMES = ("LATENT", "INFO")
OUTPUT_TOOLTIPS = ("The denoised latent.",)
FUNCTION = "sample"
CATEGORY = "iTools"
DESCRIPTION = ("Identical to the original KSampler, but additionally provides the settings used to generate the "
"image and the execution time.")
def sample(self, model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=1.0):
start_time = time.time()
result = common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
denoise=denoise)
end_time = time.time()
execution_time = f"{end_time - start_time:.3f}s"
info = f"time:{execution_time} "
info += f"seed:{seed} "
info += f"steps:{steps} "
info += f"cfg:{cfg} "
info += f"sampler:{sampler_name} "
info += f"scheduler:{scheduler} "
return result[0], info
class IToolsVaePreview:
def __init__(self):
self.output_dir = folder_paths.get_temp_directory()
self.type = "temp"
self.prefix_append = "_temp_" + ''.join(random.choice("abcdefghijklmnopqrstupvxyz") for x in range(5))
self.compress_level = 1
@classmethod
def INPUT_TYPES(cls):
return {"required":
{
# "images": ("IMAGE", ),
"samples": ("LATENT", {"tooltip": "The latent to be decoded."}),
"vae": ("VAE", {"tooltip": "The VAE model used for decoding the latent."}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("images",)
FUNCTION = "vae_preview"
OUTPUT_NODE = True
CATEGORY = "iTools"
DESCRIPTION = "Merges VAE decoding and image preview into one node."
def vae_preview(self, samples, vae, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None, ):
return_options = (vae.decode(samples["samples"]),)
images = return_options[0]
filename_prefix += self.prefix_append
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0])
results = list()
for (batch_number, image) in enumerate(images):
i = 255. * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
metadata = None
if not args.disable_metadata:
metadata = PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}_.png"
img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level)
results.append({
"filename": file,
"subfolder": subfolder,
"type": self.type
})
counter += 1
return {"ui": {"images": results}, "result": return_options}
class IToolsCheckerBoard:
def __init__(self):
...
@classmethod
def INPUT_TYPES(cls):
return {"required":
{
"width": ("INT", {"default": 1024, "min": 256, "max": 8192}),
"height": ("INT", {"default": 1024, "min": 256, "max": 8192}),
"rows": ("INT", {"default": 9, "min": 1, "max": 128}),
"cols": ("INT", {"default": 9, "min": 1, "max": 128}),
"pattern": (ChessPattern.to_list(), {"default": ChessPattern.to_list()[1]}),
"is_colored": ("BOOLEAN", {"default": False}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xfff}),
},
}
RETURN_TYPES = ("IMAGE", "MASK")
FUNCTION = "generate_checkerboard"
# OUTPUT_NODE = True
CATEGORY = "iTools"
DESCRIPTION = "Generates chessboard-like patterns, either in black and white or with random colors"
def generate_checkerboard(self, width, height, rows, cols, pattern, is_colored, seed):
_tensor = ChessTensor(width=width, height=height, rows=rows, cols=cols,
pattern=ChessPattern.from_string(pattern), colored=is_colored)
_img = pil2tensor(_tensor.pil_img)
_mask = _img[:, :, :, 0]
return _img, _mask
class IToolsLoadRandomImage:
@classmethod
def INPUT_TYPES(s):
default_dir = folder_paths.output_directory
return {"required": {
"images_directory": ("STRING", {"default": default_dir, "multiline": False}),
# "load_limit": ("INT", {"default": 100, "min": 2, "max": 200}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xfff}),
}}
RETURN_TYPES = ('IMAGE', "STRING",)
RETURN_NAMES = ('image', 'image name',)
FUNCTION = 'load_random_image'
CATEGORY = 'iTools'
DESCRIPTION = "Will return image from a given directory. it will also return the name of these image."
def load_random_image(self, images_directory, seed):
image_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif'}
images_path = Path(images_directory.replace('"', ''))
if not images_path.exists():
raise FileNotFoundError(f"Image directory {images_directory} does not exist")
all_images = [] # Store all valid image paths
for image_path in images_path.iterdir():
if image_path.suffix.lower() in image_extensions:
all_images.append(image_path)
if not all_images:
raise ValueError("No valid images found in the directory")
# Calculate the random index based on the seed
random_index = seed % len(all_images)
selected_image_path = all_images[random_index]
# Load the selected image
image = pil2tensor(Image.open(selected_image_path))
image_name = selected_image_path.stem
return image, image_name
class IToolsPreviewText:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {"forceInput": True}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ('text',)
FUNCTION = 'preview_text'
CATEGORY = 'iTools'
DESCRIPTION = "Will show text from string input."
INPUT_IS_LIST = True
OUTPUT_NODE = True
OUTPUT_IS_LIST = (True,)
def preview_text(s, text,extra_pnginfo,unique_id):
if unique_id is not None and extra_pnginfo is not None:
if not isinstance(extra_pnginfo, list):
print("Error: extra_pnginfo is not a list")
elif (
not isinstance(extra_pnginfo[0], dict)
or "workflow" not in extra_pnginfo[0]
):
print("Error: extra_pnginfo[0] is not a dict or missing 'workflow' key")
else:
workflow = extra_pnginfo[0]["workflow"]
node = next((x for x in workflow["nodes"] if str(x["id"]) == str(unique_id[0])),
None,)
if node:
node["widgets_values"] = [text]
return {"ui": {"text": text}, "result": (text,)}
# A dictionary that contains all nodes you want to export with their names
# NOTE: names should be globally unique
NODE_CLASS_MAPPINGS = {
"iToolsLoadImagePlus": IToolsLoadImagePlus,
"iToolsPromptLoader": IToolsPromptLoader,
"iToolsPromptSaver": IToolsPromptSaver,
"iToolsAddOverlay": IToolsAddOverlay,
"iToolsLoadImages": IToolsLoadImages,
"iToolsPromptStyler": IToolsPromptStyler,
"iToolsPromptStylerExtra": IToolsPromptStylerExtra,
"iToolsGridFiller": IToolsGridFiller,
"iToolsLineLoader": IToolsLineLoader,
"iToolsTextReplacer": IToolsTextReplacer,
"iToolsKSampler": IToolsKSampler,
"iToolsVaePreview": IToolsVaePreview,
"iToolsCheckerBoard": IToolsCheckerBoard,
"iToolsLoadRandomImage": IToolsLoadRandomImage,
"iToolsPreviewText": IToolsPreviewText
}
# A dictionary that contains the friendly/humanly readable titles for the nodes
NODE_DISPLAY_NAME_MAPPINGS = {
"iToolsLoadImagePlus": "iTools Load Image 🏕️",
"iToolsPromptLoader": "iTools Prompt Loader",
"iToolsPromptSaver": "iTools Prompt Saver",
"iToolsAddOverlay": "iTools Add Text Overlay",
"iToolsLoadImages": "iTools Load Images 📦",
"iToolsPromptStyler": "iTools Prompt Styler 🖌️",
"iToolsPromptStylerExtra": "iTools Prompt Styler Extra 🖌️",
"iToolsGridFiller": "iTools Grid Filler 📲",
"iToolsLineLoader": "iTools Line Loader",
"iToolsTextReplacer": "iTools Text Replacer",
"iToolsKSampler": "iTools KSampler",
"iToolsVaePreview": "iTools Vae Preview ⛳",
"iToolsCheckerBoard": "iTools Checkerboard 🏁",
"iToolsLoadRandomImage": "iTools Load Random Image 🎲",
"iToolsPreviewText": "iTools Text Preview"
}