Improve FL Load Image controls
This commit is contained in:
+253
-98
@@ -1,123 +1,278 @@
|
||||
import os
|
||||
import json
|
||||
from PIL import Image
|
||||
import torch
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from server import PromptServer
|
||||
from aiohttp import web
|
||||
import io
|
||||
from PIL import Image, ImageOps
|
||||
import torch
|
||||
|
||||
import folder_paths
|
||||
from comfy.utils import common_upscale
|
||||
|
||||
|
||||
IMAGE_EXTENSIONS = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
|
||||
DEFAULT_LOAD_SETTINGS = {
|
||||
"version": 1,
|
||||
"resize_mode": "original",
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
}
|
||||
DEFAULT_SETTINGS_JSON = json.dumps(DEFAULT_LOAD_SETTINGS, separators=(",", ":"))
|
||||
|
||||
|
||||
def available_image_files():
|
||||
input_dir = Path(folder_paths.get_input_directory()).resolve()
|
||||
os.makedirs(input_dir, exist_ok=True)
|
||||
files, _ = folder_paths.recursive_search(str(input_dir))
|
||||
result = []
|
||||
for filename in files:
|
||||
if Path(filename).suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
continue
|
||||
path = (input_dir / filename).resolve()
|
||||
try:
|
||||
path.relative_to(input_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.is_file():
|
||||
result.append(Path(filename).as_posix())
|
||||
return sorted(result, key=str.casefold)
|
||||
|
||||
|
||||
def _resolve_input_image(filename):
|
||||
if not isinstance(filename, str) or not filename.strip():
|
||||
raise ValueError("Choose an image file.")
|
||||
|
||||
filename = filename.strip()
|
||||
name, annotated_dir = folder_paths.annotated_filepath(filename)
|
||||
input_dir = Path(folder_paths.get_input_directory()).resolve()
|
||||
if annotated_dir is not None and Path(annotated_dir).resolve() != input_dir:
|
||||
raise ValueError("FL Load Image files must be inside the ComfyUI input directory.")
|
||||
|
||||
try:
|
||||
path = Path(folder_paths.get_annotated_filepath(name, str(input_dir))).resolve()
|
||||
except ValueError as e:
|
||||
raise ValueError("FL Load Image files must be inside the ComfyUI input directory.") from e
|
||||
try:
|
||||
path.relative_to(input_dir)
|
||||
except ValueError as e:
|
||||
raise ValueError("FL Load Image files must be inside the ComfyUI input directory.") from e
|
||||
if not path.is_file():
|
||||
raise ValueError(f"Image file does not exist: {filename}")
|
||||
if path.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
raise ValueError(f"Unsupported image format: {path.suffix or filename}")
|
||||
return path
|
||||
|
||||
|
||||
def _resolve_legacy_image(root_directory, selected_file):
|
||||
if not isinstance(root_directory, str) or not root_directory.strip():
|
||||
raise ValueError("FL Load Image legacy root directory is empty.")
|
||||
if not isinstance(selected_file, str) or not selected_file.strip():
|
||||
raise ValueError("Choose an image file.")
|
||||
|
||||
root = Path(root_directory).expanduser().resolve()
|
||||
path = Path(selected_file).expanduser()
|
||||
if not path.is_absolute():
|
||||
path = root / path
|
||||
path = path.resolve()
|
||||
if not root.is_dir():
|
||||
raise ValueError("FL Load Image legacy root directory does not exist.")
|
||||
try:
|
||||
path.relative_to(root)
|
||||
except ValueError as e:
|
||||
raise ValueError("FL Load Image legacy file must be inside its root directory.") from e
|
||||
if not path.is_file():
|
||||
raise ValueError(f"Image file does not exist: {selected_file}")
|
||||
if path.suffix.lower() not in IMAGE_EXTENSIONS:
|
||||
raise ValueError(f"Unsupported image format: {path.suffix or selected_file}")
|
||||
return path
|
||||
|
||||
|
||||
def resolve_image_path(root_directory, selected_file="", image=""):
|
||||
if image:
|
||||
return _resolve_input_image(image)
|
||||
return _resolve_legacy_image(root_directory, selected_file)
|
||||
|
||||
|
||||
def _parse_settings(load_settings, width_override=None, height_override=None):
|
||||
try:
|
||||
configured = json.loads(load_settings)
|
||||
except (TypeError, json.JSONDecodeError) as e:
|
||||
raise ValueError("FL Load Image settings are not valid JSON.") from e
|
||||
if not isinstance(configured, dict):
|
||||
raise ValueError("FL Load Image settings must be a JSON object.")
|
||||
|
||||
settings = DEFAULT_LOAD_SETTINGS.copy()
|
||||
settings.update(configured)
|
||||
version = settings["version"]
|
||||
if not isinstance(version, int) or isinstance(version, bool) or version != 1:
|
||||
raise ValueError(f"FL Load Image settings version {version} is unsupported.")
|
||||
|
||||
for name in ("width", "height"):
|
||||
value = settings[name]
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"FL Load Image {name} must be an integer.")
|
||||
if not 0 <= value <= 16384:
|
||||
raise ValueError("FL Load Image width and height must be between 0 and 16384.")
|
||||
|
||||
resize_mode = settings["resize_mode"]
|
||||
if resize_mode not in ("original", "fit", "crop"):
|
||||
raise ValueError("FL Load Image resize_mode must be original, fit, or crop.")
|
||||
|
||||
for name, value in (("width", width_override), ("height", height_override)):
|
||||
if value is None:
|
||||
continue
|
||||
if not isinstance(value, int) or isinstance(value, bool):
|
||||
raise ValueError(f"FL Load Image {name} override must be an integer.")
|
||||
if not 0 <= value <= 16384:
|
||||
raise ValueError("FL Load Image width and height overrides must be between 0 and 16384.")
|
||||
settings[name] = value
|
||||
|
||||
if resize_mode == "fit" and settings["width"] == 0 and settings["height"] == 0:
|
||||
raise ValueError("FL Load Image fit resize requires a width or height.")
|
||||
if resize_mode == "crop" and (settings["width"] == 0 or settings["height"] == 0):
|
||||
raise ValueError("FL Load Image crop resize requires both width and height.")
|
||||
return settings
|
||||
|
||||
|
||||
def _target_dimensions(source_width, source_height, settings):
|
||||
if settings["resize_mode"] == "original":
|
||||
return source_width, source_height
|
||||
if settings["resize_mode"] == "crop":
|
||||
return settings["width"], settings["height"]
|
||||
|
||||
width = settings["width"]
|
||||
height = settings["height"]
|
||||
if width == 0:
|
||||
scale = height / source_height
|
||||
elif height == 0:
|
||||
scale = width / source_width
|
||||
else:
|
||||
scale = min(width / source_width, height / source_height)
|
||||
return max(1, round(source_width * scale)), max(1, round(source_height * scale))
|
||||
|
||||
|
||||
def _load_image(path):
|
||||
with Image.open(path) as source:
|
||||
source = ImageOps.exif_transpose(source)
|
||||
source_width, source_height = source.size
|
||||
has_alpha = "A" in source.getbands() or "transparency" in source.info
|
||||
if has_alpha:
|
||||
rgba = source.convert("RGBA")
|
||||
image = Image.new("RGB", rgba.size, (255, 255, 255))
|
||||
image.paste(rgba, mask=rgba.getchannel("A"))
|
||||
else:
|
||||
image = source.convert("RGB")
|
||||
image_tensor = torch.from_numpy(np.array(image, dtype=np.float32) / 255.0).unsqueeze(0)
|
||||
return image_tensor, source_width, source_height, has_alpha
|
||||
|
||||
|
||||
def _resize_image(image, settings):
|
||||
source_height = int(image.shape[1])
|
||||
source_width = int(image.shape[2])
|
||||
width, height = _target_dimensions(source_width, source_height, settings)
|
||||
if (width, height) == (source_width, source_height):
|
||||
return image
|
||||
|
||||
crop = "center" if settings["resize_mode"] == "crop" else "disabled"
|
||||
image = common_upscale(image.movedim(-1, 1), width, height, "lanczos", crop)
|
||||
return image.movedim(1, -1)
|
||||
|
||||
|
||||
def _preview_reference(path):
|
||||
input_dir = Path(folder_paths.get_input_directory()).resolve()
|
||||
try:
|
||||
relative = path.relative_to(input_dir)
|
||||
except ValueError:
|
||||
return None
|
||||
return relative.name, relative.parent.as_posix() if relative.parent != Path(".") else ""
|
||||
|
||||
|
||||
class FL_LoadImage:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
files = available_image_files()
|
||||
return {
|
||||
"required": {
|
||||
"root_directory": ("STRING", {"default": "./"}),
|
||||
},
|
||||
"optional": {
|
||||
"selected_file": ("STRING", {"default": ""}),
|
||||
}
|
||||
"image": ([""] + files,),
|
||||
"load_settings": ("STRING", {"default": DEFAULT_SETTINGS_JSON, "multiline": False}),
|
||||
"width_override": ("INT", {"min": 0, "max": 16384, "forceInput": True}),
|
||||
"height_override": ("INT", {"min": 0, "max": 16384, "forceInput": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING")
|
||||
RETURN_NAMES = ("image", "file_path")
|
||||
FUNCTION = "browse_files"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🏵️Fill Nodes/Image"
|
||||
DESCRIPTION = "Loads, previews, and resizes an image from ComfyUI input."
|
||||
|
||||
def browse_files(self, root_directory, selected_file=""):
|
||||
if selected_file and os.path.isfile(selected_file):
|
||||
image = Image.open(selected_file)
|
||||
# Convert RGBA to RGB if needed (for WebP with transparency)
|
||||
if image.mode == 'RGBA':
|
||||
# Create white background and composite
|
||||
background = Image.new('RGB', image.size, (255, 255, 255))
|
||||
background.paste(image, mask=image.split()[-1]) # Use alpha channel as mask
|
||||
image = background
|
||||
elif image.mode != 'RGB':
|
||||
# Convert other modes (P, L, etc.) to RGB
|
||||
image = image.convert('RGB')
|
||||
|
||||
image_tensor = torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
return (image_tensor, selected_file)
|
||||
def browse_files(
|
||||
self,
|
||||
root_directory,
|
||||
selected_file="",
|
||||
image="",
|
||||
load_settings=DEFAULT_SETTINGS_JSON,
|
||||
width_override=None,
|
||||
height_override=None,
|
||||
):
|
||||
settings = _parse_settings(load_settings, width_override, height_override)
|
||||
path = resolve_image_path(root_directory, selected_file, image)
|
||||
loaded, source_width, source_height, has_alpha = _load_image(path)
|
||||
loaded = _resize_image(loaded, settings)
|
||||
|
||||
preview = {
|
||||
"filename": path.name,
|
||||
"source_width": source_width,
|
||||
"source_height": source_height,
|
||||
"loaded_width": int(loaded.shape[2]),
|
||||
"loaded_height": int(loaded.shape[1]),
|
||||
"resize_mode": settings["resize_mode"],
|
||||
"requested_width": settings["width"],
|
||||
"requested_height": settings["height"],
|
||||
"source_has_alpha": has_alpha,
|
||||
}
|
||||
reference = _preview_reference(path)
|
||||
if reference is not None:
|
||||
preview.update({"filename": reference[0], "subfolder": reference[1], "type": "input"})
|
||||
else:
|
||||
return (torch.zeros(1, 1, 1, 3), "")
|
||||
preview["type"] = "legacy"
|
||||
|
||||
return {
|
||||
"ui": {"fl_load_image": [preview]},
|
||||
"result": (loaded, str(path)),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, root_directory, selected_file=""):
|
||||
return selected_file
|
||||
def IS_CHANGED(
|
||||
cls,
|
||||
root_directory,
|
||||
selected_file="",
|
||||
image="",
|
||||
load_settings=DEFAULT_SETTINGS_JSON,
|
||||
width_override=None,
|
||||
height_override=None,
|
||||
):
|
||||
path = resolve_image_path(root_directory, selected_file, image)
|
||||
stat = path.stat()
|
||||
return f"{stat.st_mtime_ns}:{stat.st_size}"
|
||||
|
||||
@classmethod
|
||||
def VALIDATE_INPUTS(cls, root_directory, selected_file=""):
|
||||
if not os.path.isdir(root_directory):
|
||||
return "Root directory does not exist"
|
||||
if selected_file and not os.path.isfile(selected_file):
|
||||
return "Selected file does not exist"
|
||||
def VALIDATE_INPUTS(
|
||||
cls,
|
||||
root_directory,
|
||||
selected_file="",
|
||||
image="",
|
||||
load_settings=DEFAULT_SETTINGS_JSON,
|
||||
width_override=None,
|
||||
height_override=None,
|
||||
):
|
||||
try:
|
||||
resolve_image_path(root_directory, selected_file, image)
|
||||
_parse_settings(load_settings, width_override, height_override)
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
return True
|
||||
|
||||
|
||||
def get_directory_structure(path):
|
||||
structure = {"name": os.path.basename(path), "children": [], "path": path, "expanded": False}
|
||||
try:
|
||||
with os.scandir(path) as entries:
|
||||
for entry in entries:
|
||||
if entry.is_dir():
|
||||
structure["children"].append(get_directory_structure(entry.path))
|
||||
except PermissionError:
|
||||
pass
|
||||
return structure
|
||||
|
||||
|
||||
def get_file_list(path):
|
||||
return [f for f in os.listdir(path) if
|
||||
os.path.isfile(os.path.join(path, f)) and f.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'))]
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/fl_file_browser/get_directory_structure")
|
||||
async def api_get_directory_structure(request):
|
||||
data = await request.json()
|
||||
path = data.get("path", "./")
|
||||
|
||||
if not os.path.isabs(path):
|
||||
path = os.path.abspath(path)
|
||||
|
||||
if not os.path.exists(path):
|
||||
return web.json_response({"error": "Directory does not exist"}, status=400)
|
||||
|
||||
structure = get_directory_structure(path)
|
||||
files = get_file_list(path)
|
||||
return web.json_response({"structure": structure, "files": files})
|
||||
|
||||
|
||||
@PromptServer.instance.routes.post("/fl_file_browser/get_thumbnail")
|
||||
async def api_get_thumbnail(request):
|
||||
data = await request.json()
|
||||
path = data.get("path", "./")
|
||||
file = data.get("file", "")
|
||||
|
||||
if not os.path.isabs(path):
|
||||
path = os.path.abspath(path)
|
||||
|
||||
full_path = os.path.join(path, file)
|
||||
if not os.path.exists(full_path):
|
||||
return web.json_response({"error": "File does not exist"}, status=400)
|
||||
|
||||
try:
|
||||
with Image.open(full_path) as img:
|
||||
# Convert RGBA to RGB if needed (for WebP with transparency)
|
||||
if img.mode == 'RGBA':
|
||||
# Create white background and composite
|
||||
background = Image.new('RGB', img.size, (255, 255, 255))
|
||||
background.paste(img, mask=img.split()[-1]) # Use alpha channel as mask
|
||||
img = background
|
||||
elif img.mode != 'RGB':
|
||||
# Convert other modes (P, L, etc.) to RGB
|
||||
img = img.convert('RGB')
|
||||
|
||||
img.thumbnail((80, 80))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
return web.Response(body=buf.read(), content_type='image/png')
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -0,0 +1,290 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
import torch
|
||||
|
||||
|
||||
MODULE_PATH = pathlib.Path(__file__).parents[1] / "nodes" / "image" / "FL_LoadImage.py"
|
||||
SPEC = importlib.util.spec_from_file_location("fl_load_image_tests", MODULE_PATH)
|
||||
load_image = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(load_image)
|
||||
|
||||
|
||||
def settings(**updates):
|
||||
configured = load_image.DEFAULT_LOAD_SETTINGS.copy()
|
||||
configured.update(updates)
|
||||
return configured
|
||||
|
||||
|
||||
class LoadImageSettingsTests(unittest.TestCase):
|
||||
def test_defaults_and_missing_fields_parse(self):
|
||||
self.assertEqual(load_image._parse_settings(load_image.DEFAULT_SETTINGS_JSON), load_image.DEFAULT_LOAD_SETTINGS)
|
||||
self.assertEqual(
|
||||
load_image._parse_settings('{"version":1,"resize_mode":"crop","width":64,"height":32}'),
|
||||
settings(resize_mode="crop", width=64, height=32),
|
||||
)
|
||||
|
||||
def test_invalid_json_version_and_values_fail(self):
|
||||
for value in ("{", "[]", "null"):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
load_image._parse_settings(value)
|
||||
with self.assertRaisesRegex(ValueError, "version 2 is unsupported"):
|
||||
load_image._parse_settings('{"version":2}')
|
||||
|
||||
cases = {
|
||||
"resize_mode": "stretch",
|
||||
"width": -1,
|
||||
"height": 20000,
|
||||
}
|
||||
for name, value in cases.items():
|
||||
with self.subTest(name=name), self.assertRaises(ValueError):
|
||||
load_image._parse_settings(json.dumps(settings(**{name: value})))
|
||||
|
||||
def test_resize_requirements_are_validated(self):
|
||||
with self.assertRaisesRegex(ValueError, "requires a width or height"):
|
||||
load_image._parse_settings(json.dumps(settings(resize_mode="fit")))
|
||||
with self.assertRaisesRegex(ValueError, "requires both width and height"):
|
||||
load_image._parse_settings(json.dumps(settings(resize_mode="crop", width=512)))
|
||||
|
||||
def test_connected_dimensions_override_gui_values(self):
|
||||
configured = json.dumps(settings(resize_mode="fit", width=640, height=480))
|
||||
|
||||
effective = load_image._parse_settings(configured, width_override=320, height_override=0)
|
||||
|
||||
self.assertEqual((effective["width"], effective["height"]), (320, 0))
|
||||
with self.assertRaisesRegex(ValueError, "requires both width and height"):
|
||||
load_image._parse_settings(
|
||||
json.dumps(settings(resize_mode="crop", width=640, height=480)),
|
||||
width_override=0,
|
||||
)
|
||||
|
||||
def test_override_inputs_are_optional_connection_sockets(self):
|
||||
with tempfile.TemporaryDirectory() as input_directory:
|
||||
with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory):
|
||||
optional = load_image.FL_LoadImage.INPUT_TYPES()["optional"]
|
||||
|
||||
for name in ("width_override", "height_override"):
|
||||
with self.subTest(name=name):
|
||||
self.assertEqual(optional[name][0], "INT")
|
||||
self.assertTrue(optional[name][1]["forceInput"])
|
||||
|
||||
|
||||
class LoadImagePathTests(unittest.TestCase):
|
||||
def test_input_image_must_stay_inside_input_directory(self):
|
||||
with tempfile.TemporaryDirectory() as input_directory, tempfile.TemporaryDirectory() as outside:
|
||||
root = pathlib.Path(input_directory)
|
||||
nested = root / "nested" / "image.png"
|
||||
nested.parent.mkdir()
|
||||
nested.touch()
|
||||
outside_image = pathlib.Path(outside) / "image.png"
|
||||
outside_image.touch()
|
||||
|
||||
with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory):
|
||||
self.assertEqual(load_image._resolve_input_image("nested/image.png"), nested.resolve())
|
||||
with self.assertRaisesRegex(ValueError, "inside the ComfyUI input"):
|
||||
load_image._resolve_input_image(str(outside_image))
|
||||
|
||||
def test_legacy_image_must_stay_inside_its_root(self):
|
||||
with tempfile.TemporaryDirectory() as root_directory, tempfile.TemporaryDirectory() as outside:
|
||||
root = pathlib.Path(root_directory)
|
||||
nested = root / "nested" / "image.png"
|
||||
nested.parent.mkdir()
|
||||
nested.touch()
|
||||
outside_image = pathlib.Path(outside) / "image.png"
|
||||
outside_image.touch()
|
||||
|
||||
self.assertEqual(load_image._resolve_legacy_image(root_directory, "nested/image.png"), nested.resolve())
|
||||
with self.assertRaisesRegex(ValueError, "inside its root"):
|
||||
load_image._resolve_legacy_image(root_directory, str(outside_image))
|
||||
|
||||
def test_available_files_are_recursive_supported_and_sorted(self):
|
||||
with tempfile.TemporaryDirectory() as input_directory:
|
||||
root = pathlib.Path(input_directory)
|
||||
(root / "nested").mkdir()
|
||||
(root / "nested" / "B.WEBP").touch()
|
||||
(root / "a.png").touch()
|
||||
(root / "notes.txt").touch()
|
||||
|
||||
with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory):
|
||||
files = load_image.available_image_files()
|
||||
|
||||
self.assertEqual(files, ["a.png", "nested/B.WEBP"])
|
||||
|
||||
def test_no_source_has_an_actionable_error(self):
|
||||
with self.assertRaisesRegex(ValueError, "Choose an image"):
|
||||
load_image.resolve_image_path(".")
|
||||
|
||||
|
||||
class LoadImageProcessingTests(unittest.TestCase):
|
||||
def test_fit_and_crop_dimensions(self):
|
||||
fit = load_image._target_dimensions(1920, 1080, settings(resize_mode="fit", width=512, height=512))
|
||||
crop = load_image._target_dimensions(1920, 1080, settings(resize_mode="crop", width=512, height=512))
|
||||
|
||||
self.assertEqual(fit, (512, 288))
|
||||
self.assertEqual(crop, (512, 512))
|
||||
|
||||
def test_resize_preserves_or_changes_shape(self):
|
||||
image = torch.rand((1, 6, 10, 3))
|
||||
|
||||
self.assertIs(load_image._resize_image(image, settings()), image)
|
||||
fit = load_image._resize_image(image, settings(resize_mode="fit", width=5, height=5))
|
||||
crop = load_image._resize_image(image, settings(resize_mode="crop", width=4, height=4))
|
||||
|
||||
self.assertEqual(fit.shape, (1, 3, 5, 3))
|
||||
self.assertEqual(crop.shape, (1, 4, 4, 3))
|
||||
|
||||
def test_alpha_is_composited_over_white(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = pathlib.Path(directory) / "alpha.png"
|
||||
source = Image.new("RGBA", (2, 1))
|
||||
source.putdata([(255, 0, 0, 0), (255, 0, 0, 255)])
|
||||
source.save(path)
|
||||
|
||||
loaded, width, height, has_alpha = load_image._load_image(path)
|
||||
|
||||
self.assertEqual((width, height), (2, 1))
|
||||
self.assertTrue(has_alpha)
|
||||
np.testing.assert_allclose(loaded[0, 0, 0].numpy(), [1, 1, 1])
|
||||
np.testing.assert_allclose(loaded[0, 0, 1].numpy(), [1, 0, 0])
|
||||
|
||||
def test_grayscale_is_converted_to_rgb_and_exif_is_applied(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = pathlib.Path(directory) / "gray.png"
|
||||
Image.new("L", (3, 2), 64).save(path)
|
||||
|
||||
with mock.patch.object(load_image.ImageOps, "exif_transpose", wraps=load_image.ImageOps.exif_transpose) as transpose:
|
||||
loaded, width, height, has_alpha = load_image._load_image(path)
|
||||
|
||||
transpose.assert_called_once()
|
||||
self.assertEqual((width, height), (3, 2))
|
||||
self.assertFalse(has_alpha)
|
||||
self.assertEqual(loaded.shape, (1, 2, 3, 3))
|
||||
torch.testing.assert_close(loaded[..., 0], loaded[..., 1])
|
||||
torch.testing.assert_close(loaded[..., 1], loaded[..., 2])
|
||||
|
||||
|
||||
class LoadImageExecutionTests(unittest.TestCase):
|
||||
def test_execution_returns_path_and_preview_metadata(self):
|
||||
with tempfile.TemporaryDirectory() as input_directory:
|
||||
path = pathlib.Path(input_directory) / "nested" / "image.png"
|
||||
path.parent.mkdir()
|
||||
Image.new("RGBA", (8, 4), (0, 0, 0, 128)).save(path)
|
||||
configured = settings(resize_mode="crop", width=3, height=2)
|
||||
|
||||
with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory):
|
||||
result = load_image.FL_LoadImage().browse_files(
|
||||
".",
|
||||
image="nested/image.png",
|
||||
load_settings=json.dumps(configured),
|
||||
)
|
||||
|
||||
loaded, returned_path = result["result"]
|
||||
self.assertEqual(loaded.shape, (1, 2, 3, 3))
|
||||
self.assertEqual(returned_path, str(path.resolve()))
|
||||
preview = result["ui"]["fl_load_image"][0]
|
||||
self.assertEqual(preview["filename"], "image.png")
|
||||
self.assertEqual(preview["subfolder"], "nested")
|
||||
self.assertEqual(preview["type"], "input")
|
||||
self.assertEqual((preview["source_width"], preview["source_height"]), (8, 4))
|
||||
self.assertEqual((preview["loaded_width"], preview["loaded_height"]), (3, 2))
|
||||
self.assertEqual(preview["resize_mode"], "crop")
|
||||
self.assertEqual((preview["requested_width"], preview["requested_height"]), (3, 2))
|
||||
self.assertTrue(preview["source_has_alpha"])
|
||||
|
||||
def test_execution_uses_connected_dimensions_over_gui_dimensions(self):
|
||||
with tempfile.TemporaryDirectory() as input_directory:
|
||||
path = pathlib.Path(input_directory) / "image.png"
|
||||
Image.new("RGB", (8, 4), "blue").save(path)
|
||||
configured = settings(resize_mode="crop", width=7, height=6)
|
||||
|
||||
with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory):
|
||||
result = load_image.FL_LoadImage().browse_files(
|
||||
".",
|
||||
image="image.png",
|
||||
load_settings=json.dumps(configured),
|
||||
width_override=3,
|
||||
height_override=2,
|
||||
)
|
||||
|
||||
self.assertEqual(result["result"][0].shape, (1, 2, 3, 3))
|
||||
preview = result["ui"]["fl_load_image"][0]
|
||||
self.assertEqual((preview["loaded_width"], preview["loaded_height"]), (3, 2))
|
||||
self.assertEqual((preview["requested_width"], preview["requested_height"]), (3, 2))
|
||||
|
||||
def test_legacy_execution_remains_supported(self):
|
||||
with tempfile.TemporaryDirectory() as root_directory, tempfile.TemporaryDirectory() as input_directory:
|
||||
path = pathlib.Path(root_directory) / "image.jpg"
|
||||
Image.new("RGB", (2, 2), "blue").save(path)
|
||||
|
||||
with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory):
|
||||
result = load_image.FL_LoadImage().browse_files(root_directory, selected_file=str(path))
|
||||
|
||||
self.assertEqual(result["result"][1], str(path.resolve()))
|
||||
self.assertEqual(result["ui"]["fl_load_image"][0]["type"], "legacy")
|
||||
|
||||
def test_change_fingerprint_uses_file_metadata(self):
|
||||
with tempfile.TemporaryDirectory() as input_directory:
|
||||
path = pathlib.Path(input_directory) / "image.png"
|
||||
path.write_bytes(b"image")
|
||||
stat = path.stat()
|
||||
|
||||
with mock.patch.object(load_image.folder_paths, "get_input_directory", return_value=input_directory):
|
||||
fingerprint = load_image.FL_LoadImage.IS_CHANGED(".", image="image.png")
|
||||
|
||||
self.assertEqual(fingerprint, f"{stat.st_mtime_ns}:{stat.st_size}")
|
||||
|
||||
|
||||
class LoadImageFrontendTests(unittest.TestCase):
|
||||
def test_frontend_contains_source_preview_resize_and_lifecycle_contract(self):
|
||||
script = (pathlib.Path(__file__).parents[1] / "web" / "nodes" / "image" / "FL_LoadImage.js").read_text(encoding="utf-8")
|
||||
|
||||
for name in load_image.DEFAULT_LOAD_SETTINGS:
|
||||
with self.subTest(setting=name):
|
||||
self.assertIn(f"{name}:", script)
|
||||
for behavior in (
|
||||
'data-role="drop-zone"',
|
||||
'data-role="image"',
|
||||
'data-role="settings-menu"',
|
||||
'data-action="replace"',
|
||||
'data-setting="resize_mode"',
|
||||
'data-setting="width"',
|
||||
'data-setting="height"',
|
||||
'body.append("image", file)',
|
||||
'api.fetchApi("/upload/image"',
|
||||
'api.apiURL(`/view?',
|
||||
"migrateLegacySource()",
|
||||
'input.name === `${name}_override`',
|
||||
"handleConnectionsChanged()",
|
||||
"connectedOverrideValue(name)",
|
||||
"requested_${name}",
|
||||
"applyPreviewGeometry()",
|
||||
'data-role="image-stage"',
|
||||
'data-resize-mode="crop"',
|
||||
'container.className = "flli-container"',
|
||||
"object-position: center center",
|
||||
"grid-template-columns: minmax(0, .55fr)",
|
||||
'message?.fl_load_image?.[0]',
|
||||
"URL.revokeObjectURL",
|
||||
"MIN_NODE_WIDTH = 240",
|
||||
"MIN_NODE_HEIGHT = 230",
|
||||
):
|
||||
with self.subTest(behavior=behavior):
|
||||
self.assertIn(behavior, script)
|
||||
|
||||
def test_backend_no_longer_registers_arbitrary_file_browser_routes(self):
|
||||
script = MODULE_PATH.read_text(encoding="utf-8")
|
||||
|
||||
self.assertNotIn("PromptServer", script)
|
||||
self.assertNotIn("/fl_file_browser/", script)
|
||||
self.assertNotIn("get_directory_structure", script)
|
||||
self.assertNotIn("get_thumbnail", script)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+1150
-522
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user