1. Modify DCIImage node scale parameter from INT to FLOAT type 2. Support decimal scale range from 0.1 to 10.0 with 0.1 step increment 3. Update DCIIconBuilder.add_icon_image method to handle float scale values 4. Improve DCI reader to correctly parse and display decimal scale factors 5. Update create_dci_icon function default scales to include decimal examples 6. Add comprehensive documentation for decimal scaling functionality 7. Include test cases to verify decimal scale calculation accuracy This enhancement enables fine-grained scaling control for modern high-DPI displays, supporting scales like 1.25x, 1.5x which are commonly used in 125% and 150% display scaling scenarios. The implementation maintains backward compatibility with integer scales while providing smoother scaling transitions for better visual quality. feat: 为DCI图像添加小数缩放因子支持 1. 修改DCIImage节点的scale参数从INT类型改为FLOAT类型 2. 支持0.1到10.0范围的小数缩放,步长为0.1 3. 更新DCIIconBuilder.add_icon_image方法以处理浮点缩放值 4. 改进DCI读取器以正确解析和显示小数缩放因子 5. 更新create_dci_icon函数默认缩放值包含小数示例 6. 添加小数缩放功能的全面文档说明 7. 包含测试用例以验证小数缩放计算的准确性 此增强功能为现代高DPI显示器提供精细的缩放控制,支持1.25x、1.5x等 常用于125%和150%显示缩放场景的缩放比例。实现保持与整数缩放的向后 兼容性,同时提供更平滑的缩放过渡以获得更好的视觉质量.
520 lines
19 KiB
Python
520 lines
19 KiB
Python
import torch
|
|
import numpy as np
|
|
from PIL import Image
|
|
import os
|
|
import tempfile
|
|
import struct
|
|
from io import BytesIO
|
|
|
|
# Try relative imports first, fall back to absolute imports
|
|
try:
|
|
from .dci_format import create_dci_icon, DCIIconBuilder, DCIFile
|
|
from .dci_reader import DCIReader, DCIPreviewGenerator
|
|
except ImportError:
|
|
# Fallback for when module is loaded directly
|
|
import sys
|
|
import os
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, current_dir)
|
|
from dci_format import create_dci_icon, DCIIconBuilder, DCIFile
|
|
from dci_reader import DCIReader, DCIPreviewGenerator
|
|
|
|
|
|
class DCIPreviewNode:
|
|
"""ComfyUI node for previewing DCI file contents"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"dci_binary_data": ("BINARY_DATA",),
|
|
},
|
|
"optional": {
|
|
"grid_columns": ("INT", {"default": 4, "min": 1, "max": 10, "step": 1}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ()
|
|
RETURN_NAMES = ()
|
|
FUNCTION = "preview_dci"
|
|
CATEGORY = "DCI/Preview"
|
|
OUTPUT_NODE = True
|
|
|
|
def preview_dci(self, dci_binary_data, grid_columns=4):
|
|
"""Preview DCI file contents with in-node display"""
|
|
|
|
try:
|
|
# Use binary data
|
|
reader = DCIReader(binary_data=dci_binary_data)
|
|
source_name = "binary_data"
|
|
|
|
# Read DCI data
|
|
if not reader.read():
|
|
return {"ui": {"text": ["Failed to read DCI data"]}}
|
|
|
|
# Extract images
|
|
images = reader.get_icon_images()
|
|
if not images:
|
|
return {"ui": {"text": ["No images found in DCI file"]}}
|
|
|
|
# Generate preview
|
|
generator = DCIPreviewGenerator()
|
|
preview_image = generator.create_preview_grid(images, grid_columns)
|
|
|
|
# Convert PIL image to base64 for UI display
|
|
preview_base64 = self._pil_to_base64(preview_image)
|
|
|
|
# Generate metadata summary (always show metadata)
|
|
summary = generator.create_metadata_summary(images)
|
|
summary_text = self._format_summary(summary, source_name)
|
|
|
|
# Create UI output with image and text
|
|
ui_output = {
|
|
"ui": {
|
|
"images": [preview_base64],
|
|
"text": [summary_text]
|
|
}
|
|
}
|
|
|
|
print(f"DCI preview generated: {len(images)} images found")
|
|
return ui_output
|
|
|
|
except Exception as e:
|
|
print(f"Error previewing DCI file: {str(e)}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return {"ui": {"text": [f"Error: {str(e)}"]}}
|
|
|
|
def _pil_to_base64(self, pil_image):
|
|
"""Convert PIL image to base64 string for UI display"""
|
|
import base64
|
|
import hashlib
|
|
import time
|
|
|
|
# Convert to RGB if necessary
|
|
if pil_image.mode != 'RGB':
|
|
pil_image = pil_image.convert('RGB')
|
|
|
|
# Save to bytes buffer
|
|
buffer = BytesIO()
|
|
pil_image.save(buffer, format='PNG')
|
|
img_bytes = buffer.getvalue()
|
|
|
|
# Generate unique filename
|
|
timestamp = str(int(time.time()))
|
|
hash_obj = hashlib.md5(img_bytes)
|
|
filename = f"dci_preview_{timestamp}_{hash_obj.hexdigest()[:8]}.png"
|
|
|
|
# Save to temp directory for ComfyUI
|
|
try:
|
|
import folder_paths
|
|
temp_dir = folder_paths.get_temp_directory()
|
|
except:
|
|
temp_dir = tempfile.gettempdir()
|
|
|
|
temp_path = os.path.join(temp_dir, filename)
|
|
with open(temp_path, 'wb') as f:
|
|
f.write(img_bytes)
|
|
|
|
# Return in format expected by ComfyUI
|
|
return {
|
|
"filename": filename,
|
|
"subfolder": "",
|
|
"type": "temp"
|
|
}
|
|
|
|
def _format_summary(self, summary, source_name):
|
|
"""Format metadata summary as text"""
|
|
if not summary:
|
|
return "No metadata available"
|
|
|
|
lines = [
|
|
f"📁 DCI Source: {source_name}",
|
|
f"🖼️ Total Images: {summary['total_images']}",
|
|
f"📊 Total File Size: {summary['total_file_size']} bytes",
|
|
"",
|
|
f"📏 Icon Sizes: {', '.join(map(str, summary['sizes']))}",
|
|
f"🎭 States: {', '.join(summary['states'])}",
|
|
f"🎨 Tones: {', '.join(summary['tones'])}",
|
|
f"🔍 Scale Factors: {', '.join(map(str, summary['scales']))}",
|
|
f"🗂️ Formats: {', '.join(summary['formats'])}",
|
|
]
|
|
|
|
return "\n".join(lines)
|
|
|
|
|
|
class DCIImage:
|
|
"""ComfyUI node for creating DCI image metadata and data"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"icon_size": ("INT", {"default": 256, "min": 16, "max": 1024, "step": 1}),
|
|
"icon_state": (["normal", "disabled", "hover", "pressed"], {"default": "normal"}),
|
|
"tone_type": (["light", "dark"], {"default": "dark"}),
|
|
"scale": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.1}),
|
|
"image_format": (["webp", "png", "jpg"], {"default": "webp"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("DCI_IMAGE_DATA",)
|
|
RETURN_NAMES = ("dci_image_data",)
|
|
FUNCTION = "create_dci_image"
|
|
CATEGORY = "DCI/Export"
|
|
|
|
def create_dci_image(self, image, icon_size, icon_state, tone_type, scale, image_format):
|
|
"""Create DCI image metadata and data"""
|
|
|
|
try:
|
|
# Convert ComfyUI image tensor to PIL Image
|
|
if len(image.shape) == 4:
|
|
img_array = image[0].cpu().numpy()
|
|
else:
|
|
img_array = image.cpu().numpy()
|
|
|
|
# Convert from 0-1 range to 0-255 range
|
|
img_array = (img_array * 255).astype(np.uint8)
|
|
|
|
# Convert to PIL Image
|
|
if img_array.shape[2] == 3:
|
|
pil_image = Image.fromarray(img_array, 'RGB')
|
|
elif img_array.shape[2] == 4:
|
|
pil_image = Image.fromarray(img_array, 'RGBA')
|
|
else:
|
|
pil_image = Image.fromarray(img_array[:, :, 0], 'L').convert('RGB')
|
|
|
|
# Calculate actual size with scale
|
|
actual_size = int(icon_size * scale)
|
|
|
|
# Resize image to target size
|
|
resized_image = pil_image.resize((actual_size, actual_size), Image.Resampling.LANCZOS)
|
|
|
|
# Convert to bytes
|
|
img_bytes = BytesIO()
|
|
if image_format == 'webp':
|
|
resized_image.save(img_bytes, format='WEBP', quality=90)
|
|
elif image_format == 'png':
|
|
resized_image.save(img_bytes, format='PNG')
|
|
elif image_format == 'jpg':
|
|
# Convert to RGB if necessary for JPEG
|
|
if resized_image.mode in ('RGBA', 'LA', 'P'):
|
|
rgb_image = Image.new('RGB', resized_image.size, (255, 255, 255))
|
|
if resized_image.mode == 'P':
|
|
resized_image = resized_image.convert('RGBA')
|
|
rgb_image.paste(resized_image, mask=resized_image.split()[-1] if resized_image.mode in ('RGBA', 'LA') else None)
|
|
resized_image = rgb_image
|
|
resized_image.save(img_bytes, format='JPEG', quality=90)
|
|
|
|
img_content = img_bytes.getvalue()
|
|
|
|
# Create DCI path: size/state.tone/scale/1.0.0.0.0.0.0.0.0.0.format
|
|
# Format scale to remove unnecessary decimal places
|
|
scale_str = f"{scale:g}" # This removes trailing zeros
|
|
dci_path = f"{icon_size}/{icon_state}.{tone_type}/{scale_str}/1.0.0.0.0.0.0.0.0.0.{image_format}"
|
|
|
|
# Create metadata dictionary
|
|
dci_image_data = {
|
|
'path': dci_path,
|
|
'content': img_content,
|
|
'size': icon_size,
|
|
'state': icon_state,
|
|
'tone': tone_type,
|
|
'scale': scale,
|
|
'format': image_format,
|
|
'actual_size': actual_size,
|
|
'file_size': len(img_content)
|
|
}
|
|
|
|
print(f"Created DCI image: {dci_path} ({len(img_content)} bytes)")
|
|
return (dci_image_data,)
|
|
|
|
except Exception as e:
|
|
print(f"Error creating DCI image: {str(e)}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return ({},)
|
|
|
|
|
|
class DCIFileNode:
|
|
"""ComfyUI node for combining multiple DCI images into a DCI file"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {},
|
|
"optional": {
|
|
"dci_image_1": ("DCI_IMAGE_DATA",),
|
|
"dci_image_2": ("DCI_IMAGE_DATA",),
|
|
"dci_image_3": ("DCI_IMAGE_DATA",),
|
|
"dci_image_4": ("DCI_IMAGE_DATA",),
|
|
"dci_image_5": ("DCI_IMAGE_DATA",),
|
|
"dci_image_6": ("DCI_IMAGE_DATA",),
|
|
"dci_image_7": ("DCI_IMAGE_DATA",),
|
|
"dci_image_8": ("DCI_IMAGE_DATA",),
|
|
"dci_image_9": ("DCI_IMAGE_DATA",),
|
|
"dci_image_10": ("DCI_IMAGE_DATA",),
|
|
"dci_image_11": ("DCI_IMAGE_DATA",),
|
|
"dci_image_12": ("DCI_IMAGE_DATA",),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("BINARY_DATA",)
|
|
RETURN_NAMES = ("dci_binary_data",)
|
|
FUNCTION = "create_dci_file"
|
|
CATEGORY = "DCI/Export"
|
|
|
|
def create_dci_file(self, **kwargs):
|
|
"""Combine multiple DCI images into a DCI file"""
|
|
|
|
try:
|
|
# Collect all DCI image data
|
|
dci_images = []
|
|
for i in range(1, 13): # Support up to 12 images
|
|
dci_image_key = f"dci_image_{i}"
|
|
if dci_image_key in kwargs and kwargs[dci_image_key]:
|
|
dci_images.append(kwargs[dci_image_key])
|
|
|
|
if not dci_images:
|
|
print("No DCI images provided")
|
|
return (b"",)
|
|
|
|
# Create DCI file structure
|
|
dci_file = DCIFile()
|
|
directory_structure = {}
|
|
|
|
# Process each DCI image
|
|
for dci_image in dci_images:
|
|
path = dci_image['path']
|
|
content = dci_image['content']
|
|
|
|
# Parse path: size/state.tone/scale/filename
|
|
path_parts = path.split('/')
|
|
if len(path_parts) != 4:
|
|
print(f"Invalid DCI path format: {path}")
|
|
continue
|
|
|
|
size_dir, state_tone_dir, scale_dir, filename_part = path_parts
|
|
|
|
# Build nested directory structure
|
|
if size_dir not in directory_structure:
|
|
directory_structure[size_dir] = {}
|
|
if state_tone_dir not in directory_structure[size_dir]:
|
|
directory_structure[size_dir][state_tone_dir] = {}
|
|
if scale_dir not in directory_structure[size_dir][state_tone_dir]:
|
|
directory_structure[size_dir][state_tone_dir][scale_dir] = {}
|
|
|
|
directory_structure[size_dir][state_tone_dir][scale_dir][filename_part] = content
|
|
|
|
# Convert directory structure to DCI format
|
|
for size_dir, size_content in directory_structure.items():
|
|
state_tone_dirs = []
|
|
|
|
for state_tone_dir, state_tone_content in size_content.items():
|
|
scale_dirs = []
|
|
|
|
for scale_dir, scale_content in state_tone_content.items():
|
|
# Create files for this scale directory
|
|
scale_files = []
|
|
for filename_part, file_content in scale_content.items():
|
|
scale_files.append({
|
|
'name': filename_part,
|
|
'content': file_content,
|
|
'type': DCIFile.FILE_TYPE_FILE
|
|
})
|
|
|
|
# Create scale directory
|
|
scale_dir_content = self._create_directory_content(scale_files, dci_file)
|
|
scale_dirs.append({
|
|
'name': scale_dir,
|
|
'content': scale_dir_content,
|
|
'type': DCIFile.FILE_TYPE_DIRECTORY
|
|
})
|
|
|
|
# Create state.tone directory
|
|
state_tone_dir_content = self._create_directory_content(scale_dirs, dci_file)
|
|
state_tone_dirs.append({
|
|
'name': state_tone_dir,
|
|
'content': state_tone_dir_content,
|
|
'type': DCIFile.FILE_TYPE_DIRECTORY
|
|
})
|
|
|
|
# Add size directory to DCI
|
|
dci_file.add_directory(size_dir, state_tone_dirs)
|
|
|
|
# Generate binary data
|
|
binary_data = dci_file.to_binary()
|
|
|
|
print(f"Created DCI file with {len(dci_images)} images ({len(binary_data)} bytes)")
|
|
return (binary_data,)
|
|
|
|
except Exception as e:
|
|
print(f"Error creating DCI file: {str(e)}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return (b"",)
|
|
|
|
def _create_directory_content(self, files, dci_file):
|
|
"""Create directory content from file list"""
|
|
dir_content = BytesIO()
|
|
|
|
# Sort files by name
|
|
sorted_files = sorted(files, key=lambda x: dci_file._natural_sort_key(x['name']))
|
|
|
|
for file_info in sorted_files:
|
|
# File type (1 byte)
|
|
dir_content.write(struct.pack('<B', file_info.get('type', DCIFile.FILE_TYPE_FILE)))
|
|
|
|
# File name (63 bytes, null-terminated)
|
|
file_name_bytes = file_info['name'].encode('utf-8')
|
|
name_padded = file_name_bytes + b'\x00' * (63 - len(file_name_bytes))
|
|
dir_content.write(name_padded)
|
|
|
|
# Content size (8 bytes)
|
|
content = file_info['content']
|
|
dir_content.write(struct.pack('<Q', len(content)))
|
|
|
|
# Content
|
|
dir_content.write(content)
|
|
|
|
return dir_content.getvalue()
|
|
|
|
|
|
class BinaryFileLoader:
|
|
"""ComfyUI node for loading binary files from file system"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {},
|
|
"optional": {
|
|
"file_path": ("STRING", {"default": "", "multiline": False}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("BINARY_DATA", "STRING")
|
|
RETURN_NAMES = ("binary_data", "file_path")
|
|
FUNCTION = "load_binary_file"
|
|
CATEGORY = "DCI/Files"
|
|
|
|
def load_binary_file(self, file_path=""):
|
|
"""Load binary file from file system"""
|
|
|
|
try:
|
|
if not file_path or not os.path.exists(file_path):
|
|
print(f"File not found: {file_path}")
|
|
return (None, "")
|
|
|
|
# Read binary file
|
|
with open(file_path, 'rb') as f:
|
|
content = f.read()
|
|
|
|
# Get file info
|
|
filename = os.path.basename(file_path)
|
|
file_size = len(content)
|
|
|
|
print(f"Loaded binary file: {filename} ({file_size} bytes)")
|
|
# Return the binary content directly, not wrapped in a dictionary
|
|
return (content, file_path)
|
|
|
|
except Exception as e:
|
|
print(f"Error loading binary file: {str(e)}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return (None, "")
|
|
|
|
|
|
class BinaryFileSaver:
|
|
"""ComfyUI node for saving binary data to file system"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"binary_data": ("BINARY_DATA",),
|
|
"file_name": ("STRING", {"default": "binary_file", "multiline": False}),
|
|
},
|
|
"optional": {
|
|
"output_directory": ("STRING", {"default": "", "multiline": False}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("saved_path",)
|
|
FUNCTION = "save_binary_file"
|
|
CATEGORY = "DCI/Files"
|
|
OUTPUT_NODE = True
|
|
|
|
def save_binary_file(self, binary_data, file_name, output_directory=""):
|
|
"""Save binary data to file system"""
|
|
|
|
try:
|
|
# Check if binary_data is valid
|
|
if binary_data is None:
|
|
print("No binary data provided (None)")
|
|
return ("",)
|
|
|
|
if isinstance(binary_data, bytes) and len(binary_data) == 0:
|
|
print("Empty binary data provided")
|
|
return ("",)
|
|
|
|
if not isinstance(binary_data, bytes):
|
|
print(f"Invalid binary data type: {type(binary_data)}")
|
|
return ("",)
|
|
|
|
print(f"Processing binary data: {len(binary_data)} bytes")
|
|
|
|
# Clean up file name (remove any path separators)
|
|
clean_file_name = os.path.basename(file_name) if file_name else "binary_file"
|
|
if not clean_file_name:
|
|
clean_file_name = "binary_file"
|
|
|
|
# Determine output directory
|
|
if output_directory and os.path.exists(output_directory):
|
|
output_dir = output_directory
|
|
else:
|
|
# Use ComfyUI output directory or temp directory
|
|
try:
|
|
import folder_paths
|
|
output_dir = folder_paths.get_output_directory()
|
|
print(f"Using ComfyUI output directory: {output_dir}")
|
|
except ImportError:
|
|
# ComfyUI folder_paths not available
|
|
output_dir = tempfile.gettempdir()
|
|
print(f"ComfyUI not available, using temp directory: {output_dir}")
|
|
except Exception as e:
|
|
# Any other folder_paths related errors
|
|
output_dir = tempfile.gettempdir()
|
|
print(f"Error accessing ComfyUI output directory: {e}, using temp directory: {output_dir}")
|
|
|
|
# Create full path
|
|
full_path = os.path.join(output_dir, clean_file_name)
|
|
print(f"Target file path: {full_path}")
|
|
|
|
# Ensure directory exists
|
|
dir_path = os.path.dirname(full_path)
|
|
if dir_path:
|
|
os.makedirs(dir_path, exist_ok=True)
|
|
print(f"Ensured directory exists: {dir_path}")
|
|
|
|
# Write binary data
|
|
with open(full_path, 'wb') as f:
|
|
bytes_written = f.write(binary_data)
|
|
print(f"Wrote {bytes_written} bytes to file")
|
|
|
|
# Verify file was written correctly
|
|
if os.path.exists(full_path):
|
|
actual_size = os.path.getsize(full_path)
|
|
print(f"File saved successfully: {full_path} ({actual_size} bytes)")
|
|
return (full_path,)
|
|
else:
|
|
print(f"File was not created: {full_path}")
|
|
return ("",)
|
|
|
|
except Exception as e:
|
|
print(f"Error saving binary file: {str(e)}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return ("",)
|