feat: create intitial repository

This commit is contained in:
lisaks
2025-04-29 10:24:21 +10:00
commit 0a30f7f037
5 changed files with 807 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
# IDE specific files
.idea/
.vscode/
*.swp
*.swo
# OS specific files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
+80
View File
@@ -0,0 +1,80 @@
# Pixstri ComfyUI Comics
Pixstri is a custom plugin for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) designed to generate comic pages. It provides a hierarchical node system that allows you to create comic layouts with rows and frames, making it easy to design and preview comic pages within your ComfyUI workflows.
## Features
- **Hierarchical Comic Layout System**:
- **Page Node**: The top-level container for your comic page
- Accepts multiple Row nodes as input
- Provides image preview of the entire page layout
- **Row Node**: Organizes frames horizontally in a row
- Accepts multiple Frame nodes as input
- Provides image preview of the row layout
- **Frame Node**: Individual comic panels
- Accepts images as input
- Provides image preview of the frame
## Installation
### Option 1: Install via ComfyUI Manager (Recommended)
If you have [ComfyUI Manager](https://github.com/ltdrdata/ComfyUI-Manager) installed:
1. Open ComfyUI
2. Go to the "Manager" tab
3. Search for "Pixstri"
4. Click "Install"
### Option 2: Manual Installation
1. Clone this repository into your ComfyUI custom_nodes directory:
```bash
cd /path/to/ComfyUI/custom_nodes
git clone https://github.com/yourusername/pixstri.git
```
2. Restart ComfyUI
## Usage
### Creating a Comic Page
1. **Start with Frame Nodes**:
- Add Frame nodes to your workflow
- Connect images to each Frame node
- Adjust frame properties as needed
2. **Create Rows with Row Nodes**:
- Add Row nodes to your workflow
- Connect Frame nodes to each Row node
- Arrange frames horizontally within each row
3. **Assemble the Page**:
- Add a Page node to your workflow
- Connect Row nodes to the Page node
- The complete comic page will be output
Each node provides an image preview of its current state, allowing you to see how individual frames, rows, and the final page will look.
## Examples
### Basic Comic Page Layout
![Example Comic Workflow](https://via.placeholder.com/800x400?text=Comic+Page+Example+(Replace+with+actual+screenshot))
## Requirements
- ComfyUI
- PyTorch
- NumPy
- Pillow (PIL)
## License
MIT
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
+4
View File
@@ -0,0 +1,4 @@
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
+600
View File
@@ -0,0 +1,600 @@
import torch
import numpy as np
from PIL import Image, ImageDraw
import io
class FrameNode:
"""
FrameNode: The basic building block for a comic panel
This node processes an individual comic frame and handles
its border, padding, and other style settings.
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
"""
Define the input types for the frame node.
"""
return {
"required": {
"image": ("IMAGE",),
"border_width": ("INT", {
"default": 2,
"min": 0,
"max": 20,
"step": 1,
"display": "slider"
}),
"padding": ("INT", {
"default": 5,
"min": 0,
"max": 50,
"step": 1,
"display": "slider"
}),
"border_color": (["black", "white"],),
},
"optional": {
"caption": ("STRING", {
"multiline": True,
"default": ""
}),
}
}
RETURN_TYPES = ("IMAGE", "FRAME_DATA")
RETURN_NAMES = ("preview", "frame_data")
FUNCTION = "process_frame"
CATEGORY = "Pixstri/Comics"
def process_frame(self, image, border_width, padding, border_color, caption=None):
"""
Process a comic frame with borders and padding.
Parameters:
-----------
image : torch.Tensor
The input image tensor
border_width : int
Width of the frame border in pixels
padding : int
Internal padding between the border and the image
border_color : str
Color of the frame border ("black" or "white")
caption : str, optional
Text caption for the frame
Returns:
--------
tuple(torch.Tensor, dict)
The processed image with frame and the frame data
"""
# Convert tensor to PIL image for processing
# Take the first batch item if there are multiple
if len(image.shape) == 4:
pil_image = self._tensor_to_pil(image[0])
else:
pil_image = self._tensor_to_pil(image)
# Get the dimensions
width, height = pil_image.size
# Create a new image with border and padding
total_border = border_width + padding
new_width = width + (total_border * 2)
new_height = height + (total_border * 2)
# Create a new image with the border color
if border_color == "black":
frame_image = Image.new("RGB", (new_width, new_height), (0, 0, 0))
else:
frame_image = Image.new("RGB", (new_width, new_height), (255, 255, 255))
# If padding > 0, create an inner padding area
if padding > 0:
inner_width = width + (padding * 2)
inner_height = height + (padding * 2)
inner_x = border_width
inner_y = border_width
# Create inner padding area (white or light gray)
if border_color == "black":
inner_color = (240, 240, 240) # Light gray for black borders
else:
inner_color = (240, 240, 240) # Light gray for white borders too
# Draw the inner padding area
draw = ImageDraw.Draw(frame_image)
draw.rectangle(
[(inner_x, inner_y),
(inner_x + inner_width - 1, inner_y + inner_height - 1)],
fill=inner_color
)
# Paste the original image onto the frame
frame_image.paste(pil_image, (total_border, total_border))
# Add caption if provided
if caption and caption.strip():
draw = ImageDraw.Draw(frame_image)
# Simple caption at the bottom
# In a real implementation, you'd want more sophisticated text rendering
caption_y = new_height - border_width - 15 # Position above the border
draw.text((total_border, caption_y), caption.strip(), fill=(0, 0, 0))
# Convert back to tensor
result_tensor = self._pil_to_tensor(frame_image)
# Create frame data dictionary
frame_data = {
"width": new_width,
"height": new_height,
"image": result_tensor,
"border_width": border_width,
"padding": padding,
"caption": caption if caption else ""
}
return (result_tensor, frame_data)
def _tensor_to_pil(self, tensor):
"""Convert a PyTorch tensor to a PIL Image."""
# Handle different tensor formats
if not torch.is_tensor(tensor):
return tensor # Already a PIL image or something else
# Clone tensor to avoid modifying the original
tensor = tensor.clone()
# Standardize tensor format - handle different shapes
if len(tensor.shape) == 4: # BCHW or BHWC or B111C
if tensor.shape[1] == 3: # BCHW format
tensor = tensor[0].permute(1, 2, 0) # Convert to HWC
elif tensor.shape[3] == 3: # BHWC format
tensor = tensor[0] # Just remove batch dim
elif tensor.shape[1] == 1 and tensor.shape[2] == 1: # B111C format?
tensor = tensor[0, 0] # Extract the image
elif len(tensor.shape) == 3: # CHW or HWC
if tensor.shape[0] == 3: # CHW format
tensor = tensor.permute(1, 2, 0) # Convert to HWC
# Convert to numpy with proper scaling
if tensor.dtype == torch.uint8:
img_np = tensor.cpu().numpy()
else:
img_np = (tensor.cpu().numpy() * 255).astype(np.uint8)
# Create PIL image - handle grayscale vs RGB
if img_np.shape[-1] == 1:
img_pil = Image.fromarray(img_np[:, :, 0], 'L')
else:
img_pil = Image.fromarray(img_np)
return img_pil
def _pil_to_tensor(self, pil_image):
"""Convert a PIL Image to a PyTorch tensor in BCHW format"""
# Convert PIL to numpy array
img_np = np.array(pil_image)
# Keep as uint8 for better compatibility
if img_np.dtype != np.uint8:
img_np = (img_np * 255).astype(np.uint8)
# Convert to tensor and keep as uint8
img_tensor = torch.from_numpy(img_np)
# Return in BCHW format
if len(img_tensor.shape) == 2: # Grayscale
return img_tensor.unsqueeze(0).unsqueeze(0)
else: # RGB
# Move channels to correct position and add batch dimension
return img_tensor.permute(2, 0, 1).unsqueeze(0)
class RowNode:
"""
RowNode: Combines multiple frames horizontally into a row
This node organizes multiple comic frames into a horizontal row,
with configurable spacing and alignment.
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
"""
Define the input types for the row node.
"""
return {
"required": {
"spacing": ("INT", {
"default": 10,
"min": 0,
"max": 100,
"step": 1,
"display": "slider"
}),
"alignment": (["top", "center", "bottom"],),
},
"optional": {
"frame1": ("FRAME_DATA",),
"frame2": ("FRAME_DATA",),
"frame3": ("FRAME_DATA",),
"frame4": ("FRAME_DATA",),
"frame5": ("FRAME_DATA",),
}
}
RETURN_TYPES = ("IMAGE", "ROW_DATA")
RETURN_NAMES = ("preview", "row_data")
FUNCTION = "combine_frames"
CATEGORY = "Pixstri/Comics"
def combine_frames(self, spacing, alignment, frame1=None, frame2=None, frame3=None, frame4=None, frame5=None):
"""
Combine multiple frames into a horizontal row.
Parameters:
-----------
spacing : int
Spacing between frames in pixels
alignment : str
Vertical alignment of frames ("top", "center", "bottom")
frame1-frame5 : dict, optional
Frame data dictionaries from FrameNode
Returns:
--------
tuple(torch.Tensor, dict)
The combined row image and the row data
"""
# Collect all provided frames (filter out None values)
frames = [f for f in [frame1, frame2, frame3, frame4, frame5] if f is not None]
if not frames:
# Return empty image and data if no frames
empty_img = Image.new("RGB", (400, 200), (240, 240, 240))
draw = ImageDraw.Draw(empty_img)
draw.text((150, 90), "No frames provided", fill=(0, 0, 0))
empty_tensor = self._pil_to_tensor(empty_img)
return (empty_tensor, {"width": 400, "height": 200, "frames": []})
# Calculate the total width and maximum height
total_width = sum(frame["width"] for frame in frames) + spacing * (len(frames) - 1)
max_height = max(frame["height"] for frame in frames)
# Create a new image for the row
row_image = Image.new("RGB", (total_width, max_height), (255, 255, 255))
# Position to place the next frame
x_offset = 0
# Track frame positions for row data
frame_positions = []
# Place each frame on the row
for frame in frames:
frame_tensor = frame["image"]
frame_pil = self._tensor_to_pil(frame_tensor)
frame_width = frame["width"]
frame_height = frame["height"]
# Calculate Y position based on alignment
if alignment == "top":
y_offset = 0
elif alignment == "bottom":
y_offset = max_height - frame_height
else: # center
y_offset = (max_height - frame_height) // 2
# Paste the frame onto the row
row_image.paste(frame_pil, (x_offset, y_offset))
# Record the frame position
frame_positions.append({
"x": x_offset,
"y": y_offset,
"width": frame_width,
"height": frame_height
})
# Update the x offset
x_offset += frame_width + spacing
# Convert to tensor for preview
result_tensor = self._pil_to_tensor(row_image)
# Create row data dictionary
row_data = {
"width": total_width,
"height": max_height,
"image": result_tensor,
"frames": frame_positions,
"spacing": spacing,
"alignment": alignment
}
return (result_tensor, row_data)
def _tensor_to_pil(self, tensor):
"""Convert a PyTorch tensor to a PIL Image"""
if not torch.is_tensor(tensor):
return tensor
# Clone tensor to CPU
tensor = tensor.cpu().clone()
# Handle BCHW format (which is what FrameNode outputs)
if len(tensor.shape) == 4: # BCHW format
tensor = tensor[0] # Remove batch dimension
if tensor.shape[0] == 3 or tensor.shape[0] == 1: # CHW format
tensor = tensor.permute(1, 2, 0) # CHW to HWC
# Convert to numpy array
if tensor.dtype == torch.uint8:
img_np = tensor.numpy()
else:
img_np = (tensor.numpy() * 255).astype(np.uint8)
# Create PIL image
if len(img_np.shape) == 2:
return Image.fromarray(img_np, 'L')
elif img_np.shape[-1] == 1:
return Image.fromarray(img_np[:, :, 0], 'L')
else:
return Image.fromarray(img_np)
def _pil_to_tensor(self, pil_image):
"""Convert a PIL Image to a PyTorch tensor in BCHW format for ComfyUI compatibility."""
# Convert PIL to numpy
img_np = np.array(pil_image).astype(np.float32) / 255.0
# Ensure proper dimensions for RGB images
if len(img_np.shape) == 2: # Grayscale
img_np = np.expand_dims(img_np, axis=2) # Add channel dimension
# Convert HWC to BCHW format (batch, channels, height, width)
img_np = img_np.transpose(2, 0, 1) # HWC to CHW
img_tensor = torch.from_numpy(img_np).unsqueeze(0) # Add batch dimension
return img_tensor
class PageNode:
"""
PageNode: Combines multiple rows vertically into a complete comic page
This node stacks multiple rows to create a full comic page layout,
with configurable spacing and alignment.
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
"""
Define the input types for the page node.
"""
return {
"required": {
"spacing": ("INT", {
"default": 15,
"min": 0,
"max": 100,
"step": 1,
"display": "slider"
}),
"alignment": (["left", "center", "right"],),
"page_color": (["white", "off-white", "light-gray"],),
"page_width": ("INT", {
"default": 1000,
"min": 500,
"max": 3000,
"step": 10,
"display": "slider"
}),
"page_margin": ("INT", {
"default": 30,
"min": 0,
"max": 100,
"step": 5,
"display": "slider"
}),
},
"optional": {
"row1": ("ROW_DATA",),
"row2": ("ROW_DATA",),
"row3": ("ROW_DATA",),
"row4": ("ROW_DATA",),
"row5": ("ROW_DATA",),
"title": ("STRING", {
"multiline": False,
"default": ""
}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("page_image",)
FUNCTION = "create_page"
CATEGORY = "Pixstri/Comics"
def create_page(self, spacing, alignment, page_color, page_width, page_margin,
row1=None, row2=None, row3=None, row4=None, row5=None, title=None):
"""
Create a comic page by combining multiple rows.
Parameters:
-----------
spacing : int
Vertical spacing between rows in pixels
alignment : str
Horizontal alignment of rows ("left", "center", "right")
page_color : str
Background color of the page
page_width : int
Width of the page in pixels
page_margin : int
Margin around the page content in pixels
row1-row5 : dict, optional
Row data dictionaries from RowNode
title : str, optional
Title for the comic page
Returns:
--------
torch.Tensor
The complete comic page image
"""
# Collect all provided rows (filter out None values)
rows = [r for r in [row1, row2, row3, row4, row5] if r is not None]
if not rows:
# Return empty page if no rows
empty_img = Image.new("RGB", (page_width, page_width * 1.4), (240, 240, 240))
draw = ImageDraw.Draw(empty_img)
draw.text((page_width//2 - 80, page_width//2), "Empty Comic Page", fill=(0, 0, 0))
empty_tensor = self._pil_to_tensor(empty_img)
return (empty_tensor,)
# Set page background color
if page_color == "white":
bg_color = (255, 255, 255)
elif page_color == "off-white":
bg_color = (252, 252, 245)
else: # light-gray
bg_color = (240, 240, 240)
# Calculate heights and determine max width
total_height = sum(row["height"] for row in rows) + spacing * (len(rows) - 1) + (page_margin * 2)
max_row_width = max(row["width"] for row in rows)
# Determine page width (use specified width or fit content)
content_width = max_row_width + (page_margin * 2)
if content_width > page_width:
page_width = content_width
# Add extra space for title if provided
title_height = 0
if title and title.strip():
title_height = 50 # Space for title
total_height += title_height
# Create the page canvas
page_image = Image.new("RGB", (page_width, total_height), bg_color)
draw = ImageDraw.Draw(page_image)
# Add title if provided
y_offset = page_margin
if title and title.strip():
# Draw title text
draw.text((page_width//2 - len(title)*4, y_offset), title.strip(), fill=(0, 0, 0))
y_offset += title_height
# Place each row on the page
for row in rows:
row_tensor = row["image"]
row_pil = self._tensor_to_pil(row_tensor)
row_width = row["width"]
row_height = row["height"]
# Calculate X position based on alignment
if alignment == "left":
x_offset = page_margin
elif alignment == "right":
x_offset = page_width - row_width - page_margin
else: # center
x_offset = (page_width - row_width) // 2
# Paste the row onto the page
page_image.paste(row_pil, (x_offset, y_offset))
# Update the y offset
y_offset += row_height + spacing
# Convert to tensor
result_tensor = self._pil_to_tensor(page_image)
return (result_tensor,)
def _tensor_to_pil(self, tensor):
"""Convert a PyTorch tensor to a PIL Image."""
if not torch.is_tensor(tensor):
return tensor # Already a PIL image or something else
# Clone and move to CPU
tensor = tensor.cpu().clone()
# Handle uint8 tensors by converting to float
if tensor.dtype == torch.uint8:
tensor = tensor.float() / 255.0
# Handle B111C format specifically - this is the problematic format
if len(tensor.shape) == 4:
if tensor.shape[1] == 1 and tensor.shape[2] == 1: # B111C format
tensor = tensor.squeeze(1).squeeze(1) # Remove singleton dimensions
elif tensor.shape[3] == 3: # BHWC format
tensor = tensor.permute(0, 3, 1, 2) # BHWC to BCHW
# Ensure we're in BCHW format and remove batch dimension
if len(tensor.shape) == 4:
tensor = tensor[0] # Remove batch dimension to get CHW
# Convert CHW to HWC for PIL
if len(tensor.shape) == 3 and (tensor.shape[0] == 3 or tensor.shape[0] == 1):
tensor = tensor.permute(1, 2, 0) # CHW to HWC
# Convert to numpy with proper scaling
img_np = (tensor.numpy() * 255).astype(np.uint8)
# Create PIL image - handle grayscale vs RGB
if len(img_np.shape) == 2:
img_pil = Image.fromarray(img_np, 'L')
elif img_np.shape[-1] == 1:
img_pil = Image.fromarray(img_np[:, :, 0], 'L')
else:
img_pil = Image.fromarray(img_np)
return img_pil
def _pil_to_tensor(self, pil_image):
"""Convert a PIL Image to a PyTorch tensor in BCHW format"""
# Convert PIL to numpy array
img_np = np.array(pil_image)
# Keep as uint8 for better compatibility
if img_np.dtype != np.uint8:
img_np = (img_np * 255).astype(np.uint8)
# Convert to tensor and keep as uint8
img_tensor = torch.from_numpy(img_np)
# Return in BCHW format
if len(img_tensor.shape) == 2: # Grayscale
return img_tensor.unsqueeze(0).unsqueeze(0)
else: # RGB
# Move channels to correct position and add batch dimension
return img_tensor.permute(2, 0, 1).unsqueeze(0)
# A dictionary that contains all nodes you want to export with their names
NODE_CLASS_MAPPINGS = {
"FrameNode": FrameNode,
"RowNode": RowNode,
"PageNode": PageNode
}
# A dictionary that contains the friendly/humanly readable titles for the nodes
NODE_DISPLAY_NAME_MAPPINGS = {
"FrameNode": "Comic Frame",
"RowNode": "Comic Row",
"PageNode": "Comic Page"
}
+3
View File
@@ -0,0 +1,3 @@
torch>=1.7.0
numpy>=1.19.0