Improve FL Load Image controls

This commit is contained in:
Fillip
2026-08-07 19:40:16 -07:00
parent 9afaf23407
commit 4df9ae993c
3 changed files with 1693 additions and 620 deletions
+253 -98
View File
@@ -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)
+290
View File
@@ -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()
File diff suppressed because it is too large Load Diff